Repository navigation
Trace nested and resumed agents whole, once, with their real usage - #4
Merged
Merged
Conversation
The other session's entry carried a hard-coded 2026-09-10 timestamp, which the 30-day sweep in save_hook_state now deletes, so the test failed for a reason unrelated to the lock. Use the current time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy
Three gaps measured on a long multi-agent session against live Langfuse: - A background agent that ends a turn while its own background work runs notifies "completed" with an interim result, then resumes and keeps writing. The hook emitted each agent once, at that first notification, so everything it did and launched afterwards (at any depth) was lost. An agent registry (per-session file beside the state file) now records each emitted agent's parent span and exported keys; every firing re-emits agents whose transcript grew, under the same parent. - Observation ids are now derived from the trace id plus a stable key (message id, tool_use id, agent id), so a re-emitted agent span or an incomplete observation replaces its earlier copy (ReplacingMergeTree on span id) instead of duplicating it. CC_LANGFUSE_DETERMINISTIC_IDS=false restores random ids. - Claude Code 2.1.301 writes api-request / api-request-blob echo rows. A blob carries message.role without id, usage or timestamp; the role fallback read it as a chat row, giving one zero-usage generation per echoed assistant message and phantom turns per echoed user message. Typed non-chat rows no longer fall back to message.role, and echo rows are dropped at read time. Also: JSONL is split on newline only (str.splitlines broke rows holding a raw U+2028/U+0085 and dropped them); workflow and subagent maps pass through workflow agents and subagents; a queue remove row carrying a notification clears it from the emission gate; and a --backfill mode re-uploads a finished session from its transcript into namespaced traces with deterministic ids (--dry-run, --replace-session-traces). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy
Review found the previous commit relied on Langfuse merging two exports of
one observation id. Langfuse v4 reads do not (events_core/events_full are
read without FINAL or LIMIT BY; duplicates collapse only at background
merge), so each re-send counted twice.
- No re-send at all. While an agent runs, only observations that can no
longer change ship; the agent span ships once, when the agent settles
(no pending async launch, no unresolved tool call) or at SessionEnd.
Children exported earlier point at its deterministic span id. A sync
launch's tool_result and a workflow journal result also count as proof
the agent is done.
- Notifications a working agent receives arrive as attachment rows
({"type": "queued_command", "prompt": "<task-notification>..."}). They
were not recognised, so every async launch inside a subagent stayed
unresolved and its later generations were never final.
- Backfill: refuses a --session-id that is not the transcript's own, a
transcript modified in the last 30 min (unless --force), and a namespace
that already holds traces of the session; uploads and flushes before it
deletes; deletes only traces named "Claude Code Turn" outside its own
set; --dry-run prints the delete plan.
- Registry: keeps the previous version as .bak and loads it when the file
is torn, logs a WARNING when late work may be lost, sweeps stray .tmp.
- Tests count raw exports (no server-side dedupe assumed); every new guard
has a test that a mutation of it fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy
…iled uploads
Second review found the once-only export trades duplication for loss:
- A subagent receives a notification as an isMeta user row ("[SYSTEM
NOTIFICATION ...]", origin kind task-notification). The injected-context
branch ran first and ate it, so those launches never resolved and the
agent never settled before SessionEnd. Notifications are now matched
before the isMeta check.
- An agent's last message is written as several rows; the earlier ones
carry partial usage and stop_reason null. It counted as finished, so a
generation could ship with partial usage and the agent settle early. The
last message of an agent is now held until its last row has a
stop_reason (a later message also proves it complete).
- A launch that never notifies held its turn (or agent) until SessionEnd: a
successful TaskStop result now resolves the stopped task, a waiting main
turn ships after CC_LANGFUSE_MAX_DEFER_HOURS (default 12), an agent idle
that long settles, and one killed mid-message settles after 30 min idle.
- Backfill: flush() never raises on a rejected batch, so the upload is now
read back (trace list with observation ids, retried up to 10 min) and
nothing is deleted unless every uploaded observation is visible; exit 4
otherwise. The live-session guard now takes the newest mtime under the
session directory.
- Tests for each, and a mutation harness: 26 guard mutations, all fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy
…ecovery Round 3 held with residuals: - A registered agent was skipped whenever its size had not changed and its span had shipped, so a woken agent's final message written with stop_reason null was never exported, and the idle bounds never fired for an agent first seen fresh. The registry now records whether an agent is at rest and which message it holds back; such agents are re-read once their idle bound is due, and always at SessionEnd. - A background shell (backgroundTaskId) keeps its agent awake until the shell notifies; task-id-only shell notifications resolve through the agent's own launch results. Main turns keep their gate (documented). - Backfill: --verify-only re-reads an earlier upload without uploading (ids are deterministic) and can finish the replacement; --verify-minutes sets the read-back window; the exit-4 message and README name the recovery path; an empty expectation never counts as verified. - Tests for every case the review listed; the mutation harness grows to 38 mutations, all failing the suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the gaps measured on a long multi-agent session (2,783 agent transcripts) against live Langfuse: agents missing at depth 2 and 3, resumed background agents cut short, and zero-usage generations since Claude Code 2.1.301. It also adds a guarded backfill that repairs past sessions.
Review rounds 1 and 2 refuted earlier versions of this PR (round 1: re-sends counted twice; round 2: once-only export lost work). Round 3 held, with residuals that the last commit closes. This body describes the current tip.
Root causes (VERIFIED on the real transcripts)
completednotification marked "may be interim". The same task-id notifies again later. The hook emitted the agent at the first notification and never again.{"type":"queued_command","prompt":"<task-notification>…"}, not as a user row.api-request,api-request-shapeandapi-request-blobrows. A blob hasmessage.rolebut no id, usage or timestamp.str.splitlines()dropped rows that contain a raw U+2028 or U+0085. In two agents that was the row holding a split message's final usage.[SYSTEM NOTIFICATION …]with origin kindtask-notification. The injected-context branch ran before the notification branch and ate them (1,314 such rows). They are now matched first.stop_reason: null. Only the main transcript is guaranteed complete at a Stop. An agent's LAST message is now held until its last row has a stop_reason; a later message also proves an earlier one complete (319 earlier messages end with a null stop_reason and are complete).removerows now carry the notification content. A notification routed to a subagent was counted as undelivered, which held the main turn open.Fix
events_coreandevents_fullare read without FINAL or LIMIT BY, and duplicates collapse only at a background merge. So nothing is re-sent, and the first version's re-send design is gone.CC_LANGFUSE_MAX_DEFER_HOURS(default 12) settles whatever it waits on.stop_reason: null) ship, and makes the idle bounds work for an agent first seen fresh.backgroundTaskId, fromrun_in_backgroundor a command that outlived its timeout) keeps its agent awake until the shell's notification, which may carry only the task id. This is counted for agents only; main turns keep their own gate.CC_LANGFUSE_MAX_DEFER_HOURSships as it is.sha256(trace id + message, tool_use or agent id)) let children exported in an earlier firing point at an agent span that ships later, and make backfill ids reproducible. They are not relied on for deduplication.CC_LANGFUSE_DETERMINISTIC_IDS=falsefalls back to one-shot agents (the old behavior).langfuse_agents.<key>.jsonbeside the state file, under the session lock. It records each agent's first parent, its first-seen start, the keys it has exported, and whether its span has shipped..bakand is loaded if the file is torn. When late work may be lost, a WARNING is logged. The registry cannot be rebuilt from transcripts, because re-sending would double count. Stray.tmpfiles are swept.message.role.\nonly.removerow that carries a notification takes it off the gate.Backfill
uv run --script hooks/langfuse_hook.py --backfill <session-id>.jsonl [--dry-run] [--replace-session-traces] [--namespace NAME] [--force]backfill-v1.flush()never raises on a rejected batch, so after the upload the backfill lists the session's traces with their observation ids, retrying for up to 10 min. Unless every uploaded observation is visible, it deletes nothing and exits 4.--verify-onlyand the same--namespace. It uploads nothing (the ids are deterministic), re-reads, and with--replace-session-tracesfinishes the replacement.--verify-minuteschanges the read-back window. If the upload really failed, use a new--namespacewith--replace-session-traces, which also removes the partial upload. An empty expectation never counts as verified.--session-idthat is not the transcript's own: both the file name and the rows must match.--force.--replace-session-traces.Claude Code Turnthat are outside the backfill's own set. Traces under any other name are never touched.--dry-run, it lists them (a read-only call) and deletes nothing.Tests and controls
Suite: 359 tests pass on Python 3.10, 3.11 and 3.12. Ruff is clean, and the hook smoke check exits 0.
Agent completion tests (
test_agent_completion.py).New backfill tests. A fake server records list, delete and flush order. It covers a rejected upload (exit 4, nothing deleted), a partly landed upload (nothing deleted), rows of another session, and a recently written agent file.
No server-side dedupe is assumed anywhere. Tests count raw exports.
Fixture
resumed_background_agent(synthetic rows in 2.1.301 shapes):Backfill guard tests use a fake trace API that records list, delete and flush order:
--force;Round-4 tests.
--verify-onlysuccess and refusal, and an empty expectation refused.Mutation check: 38 mutations were applied to a scratch copy of the hook, one per guard, and every one fails the suite. The earlier 15:
splitlinesrestored, queueremoveignored.The 11 added in round 3:
completed_onlyoff;The 12 added in round 4:
Real-data oracle (read-only snapshot, nothing sent):
Firing-by-firing replay of the m038 batch window (65 Stops plus SessionEnd, each Stop seeing files truncated to its time): 5,588 exports onto 5,588 ids, so 0 re-sends. All 54 of 54 agents have one span each, and usage equals the transcript for every agent.
Reviewer's probe, repeated on real data. Appending one row to the codegen agent
a1e18cad…and firing, with history capture on, exports 0 observations. Appending one new assistant message exports exactly 1.Stop-only versus SessionEnd on a fresh read-only session copy (one firing over the whole session, real file mtimes preserved).
No test touches the network. Clients are fakes or the in-process recorder, and the comparison runs used
http://127.0.0.1:9with the keys unset.Live-sequence replay at this tip (the reviewer's scripts, on a fresh read-only session copy). It re-runs 1,046 real Stops, each seeing every file as it was at that moment with mtimes and a faked clock, plus SessionEnd.
Residuals
wf_f74c5905-29c/agent-ad6a4ece…) is never registered: its transcript holds no assistant message (started, then stopped), so there is nothing to emit.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy