Skip to content

telemetry: attribute Claude Code subagent dispatches to their Delivery (Refs #5171) - #5178

Merged
EHotwagner merged 6 commits into
mainfrom
routine/claude-subagent-dispatch-attribution
Nov 1, 2026
Merged

EHotwagner merged 6 commits into
mainfrom
routine/claude-subagent-dispatch-attribution

Conversation

@EHotwagner

@EHotwagner EHotwagner commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Refs #5171. Installed proof follows the coherent release (Root's held remainder below).

Design

Passive capture already reads a selected subagent transcript <project>/<session>/subagents/agent-<id>.jsonl. It now also reads, bounded and through the same no-follow opener, three things Claude Code already writes:

  • the sibling agent-<id>.meta.json: toolUseId, spawnDepth, parentAgentId;
  • the transcript's first record: its timestamp and the first line of the brief;
  • the launch result for that toolUseId and agent in the spawning transcript: resolvedModel.

It emits only kinds the receiver already admits, so no receiver change and no store migration:

fact identity and content
expected-dispatch id sha256("claude-code-subagent-dispatch/1"␟session␟toolUseId)[:32]; relation child; parent is the session (claude-code-session-dispatch/1) or, nested, the spawning subagent's dispatch; runtime claude-code-subagent; expectedAt = first record time (host-wall); activation id names the session, no activation fact
invocation-lineage invocation id = the subagent's existing passive thread id, so per-response provider-usage-observation/1 joins on threadId; root = session id
runtime-admission requestedModel = launch resolvedModel; featureId = item; attemptId = dispatch id; producerStream = claude-code-subagent:<role>

The resolved (observed) model is the per-response model on each joined usage row.

Item. It comes only from the brief's first line, fsgg-dispatch: item=OWNER/REPO#N role=worker|critic|analyst|report. If the marker is missing or malformed, or the first record is unreadable, no dispatch rows are written. Instead an item-less diagnostic item-unattributed:<reason> names the dispatch, the invocation and the reason in its identity, so a re-read after a producer-state reset that finds another reason adds a fact rather than conflicting. Trailing whitespace on the marker line is trimmed. expected_dispatches.item_id is NOT NULL, and a sentinel item would be a guess.

Gaps.

  • Missing or invalid metadata, including a nested spawn whose spawning meta is missing: dispatch-identity-unavailable.
  • Launch result not yet written: the admission waits. If the transcript retires without one: runtime-gap claude-launch-result-unavailable.

Re-read and resume. Identities and contents are deterministic, so a re-read replays byte-identical facts. A per-source status (cursor dispatch, retired entry d) stops a pass from re-emitting, so a resumed agent (SendMessage) adds turns to the same dispatch.

Limits.

  • No terminal fact is written.
  • claude-code-subagent is not a settled budget runtime, so an item with these dispatches keeps its budget population open.
  • Usage stays outside qualified item totals, as before.

The design is also recorded in docs/roadmaps/utel-operational-completeness.md (new subsection).

Guidance

work-board/SKILL.md (after the boundary-order paragraph) and pnext-item/SKILL.md (step 1) now say that every dispatch brief starts with the marker. This is in both roots, which are byte-identical.

I did not edit lifecycle-contract.md or status-lifecycle.md (#5136 PR 6) or any other skill (#5172).

The following were refreshed:

  • driver and coordination-kit manifests;
  • repos.lock (pnext-item);
  • the skills.yml work-board digest and updated:;
  • the writer-census hash for skills.yml;
  • skills.CHANGELOG.

Version. 0.114.2 was cut from 55a24ab6 before this PR and is published. This branch merged origin/main, including #5201 (FakeClock), and moves the coherent set to 0.114.3: Directory.Build.props, a CLI 0.114.3 PackageReleaseNotes heading for this change, and the census digest. Kit pr-arm reports 0.114.3 is ahead of the newest published FS.GG.Kit (0.114.2). This is source preparation only; the release is a separate step.

Tests (local, exact source before push)

  • 0.114.3 head (4daf0515, main merged): the two touched telemetry suites Passed: 45, Failed: 0; Core TelemetryTests Passed: 20, Failed: 0; V2TendProposeTests Passed: 12, Failed: 0; engine release-notes fixture: 32 passed, 0 failed; check-engine-release-notes ok; signature-doc-siting 90 passed, 0 failed; manifests current; repos-registry: OK; skill-union-assert: OK; v1-writer-census: PASS.
  • Review fixes (head a45dd7a8): ClaudeSubagentDispatchTests|WorkspaceTelemetryApplicationTests Passed: 45, Failed: 0 (11 dispatch tests). The new tests are a prompt-unavailable gap (F5) and a reset producer state with a different reason (F1): the receiver accepts it with 2 gaps and 2 usage observations. A mutation that drops the reason from the identity fails 4 tests, and in the reset test the receiver reports "applied":0,"rejected":1.
  • ClaudeSubagentDispatchTests (new, CLI): Passed: 9, Failed: 0. These run the real capture, submit each produced batch through an authenticated generic receipt into a fresh local store, and run the join below verbatim. Causal controls:
    • attributed join, where the counters equal the fixture's per-response usage;
    • re-read from fresh producer state gives identical identities and digests, and the receiver reports accepted 0, replayed 3 with one dispatch;
    • an unchanged transcript produces no batch;
    • a resumed agent adds one response to the same dispatch;
    • missing meta;
    • missing launch result: waits, then joins later;
    • missing launch result at retirement: gap, and a later append does not re-emit;
    • missing marker, including a marker on line 2;
    • two malformed markers;
    • nested spawnDepth 2, plus an orphan control without the spawning meta;
    • a main-session transcript gets no status.
  • Core TelemetryTests: 2 new tests, for the marker grammar (4 marked, 4 missing, 8 malformed) and the meta, opening and launch decoders. Both pass.
  • Related suites: WorkspaceTelemetryApplicationTests|ClaudeSubagentDispatchTests|TelemetryStoreApplicationTests|TelemetryReceiptTests Passed: 186, Failed: 0; HostPassiveReceiptTests|HostSchemaProfileTests Passed: 3, Failed: 0.
  • Mutations, each reverted:
    • ignoring the persisted status fails 3 tests;
    • guessing the marker from later lines fails 1 CLI test and 1 Core test;
    • a non-deterministic dispatch id fails 5 tests.
  • signature-doc-siting fixture (after the comment repair on the second head): 90 passed, 0 failed.
  • After merging main (process: fdev lane-capacity probe, review-finding triage before merge, push before stop (Refs #5172) #5173, skills: the board-tend contract amendment, C1 Delivery closure and the delivery declaration (Refs #5136) #5168, ci: fold eight tiny always-run gates into one always-gates job (Refs #5182) #5186, coord: board-v2 tend reads the board-tend contract marker from the installed contract (Refs #5174) #5188), on the merged tree: ClaudeSubagentDispatchTests|WorkspaceTelemetryApplicationTests Passed: 43, Failed: 0; Core TelemetryTests Passed: 20, Failed: 0; kit pr-arm ok; manifests current; repos-registry: OK; census PASS.
  • Checks:
    • skill-quality: OK — 29 skills;
    • skill-union-assert: OK (29/29 byte-identical);
    • skill-view-parity: AGREE;
    • check-skillmirror-freshness: OK;
    • v1-writer-census: PASS (structural);
    • repos-registry: OK;
    • generate-driver-manifest: current.
  • Real-data dry run, read-only. Capture ran into a scratch state root with an in-memory enqueue and nothing was queued. It covered 21 real subagent transcripts of one fdev session: 6 dispatches attributed with an admission, 15 item-unattributed:missing-marker (briefs from before the marker), and 1112 usage observations. Two independent runs gave identical dispatch ids, and one id matched an independent Python recomputation.

Fixture clocks. Record timestamps are fixed literals used as input. The retirement test ages its file relative to the host wall clock. Stores and homes are per-test temp sandboxes. The transcript, meta and launch records are synthetic, and no private transcript text is used.

Held remainder: installed proof (Root)

This needs the engine released and installed through the coherent route (0.114.2). Then read the live store read-only:

sudo -n -u fsgg-store-dev XDG_RUNTIME_DIR=/run/user/30033 podman exec -i -u telemetry fsgg-store \
  sqlite3 -readonly /srv/telemetry/data/fdev-vm-store14-v1/fdev-vm-20261030/telemetry.sqlite3 < join.sql

join.sql (identical to ClaudeSubagentDispatchTests.JoinSql):

-- Claude Code subagent dispatches joined to their Delivery and per-response usage (read-only).
SELECT d.item_id, d.dispatch_id, l.invocation_id, d.parent_dispatch_id, d.expected_at,
       a.producer_stream, a.requested_model,
       count(u.identity) AS responses,
       group_concat(DISTINCT json_extract(u.canonical,'$.model')) AS observed_models,
       sum(json_extract(u.canonical,'$.input')) AS input,
       sum(json_extract(u.canonical,'$.cachedInput')) AS cached_input,
       sum(json_extract(u.canonical,'$.cacheWriteInput')) AS cache_write_input,
       sum(json_extract(u.canonical,'$.output')) AS output,
       sum(json_extract(u.canonical,'$.total')) AS total
FROM expected_dispatches d
JOIN invocation_lineage l ON l.item_id=d.item_id AND l.dispatch_id=d.dispatch_id
LEFT JOIN runtime_admissions a ON a.item_id=l.item_id AND a.invocation_id=l.invocation_id
LEFT JOIN current_ingest_facts u ON u.kind='provider-usage-observation/1' AND json_extract(u.canonical,'$.threadId')=l.invocation_id
WHERE d.runtime='claude-code-subagent'
GROUP BY d.item_id, d.dispatch_id, l.invocation_id
ORDER BY d.expected_at, d.dispatch_id;

Gaps:

SELECT identity, code FROM health_diagnostics WHERE code LIKE 'item-unattributed:%' OR code='dispatch-identity-unavailable';
SELECT invocation_id, code FROM runtime_gaps WHERE code='claude-launch-result-unavailable';

To check that a row's counters equal the transcript's own usage, sum that subagent transcript's completed assistant records. Count each message.id once and take a record only if it has a non-null stop_reason, a model other than <synthetic>, and a preceding prompt. Input is input_tokens + cache_read_input_tokens + cache_creation_input_tokens.

Only marked briefs (from 2026-11-01) are attributed. Capture is forward-only: a transcript already retired by the old engine gets dispatch facts only if it grows again.

🤖 Generated with Claude Code

EHotwagner and others added 3 commits November 1, 2026 10:38
…y (Refs #5171)

Passive capture now derives dispatch facts for a selected Claude Code subagent
transcript from data Claude Code already writes: the sibling agent-<id>.meta.json,
the transcript's first record, and the launch result in the spawning transcript.
It reuses expected-dispatch, invocation-lineage, runtime-admission, runtime-gap and
diagnostic, so neither the receiver nor the store schema changes.

- Dispatch id = sha256("claude-code-subagent-dispatch/1", session, toolUseId)[:32];
  relation child of the session (or the spawning subagent for a nested spawn);
  runtime claude-code-subagent; expectedAt from the first record.
- Invocation id = the subagent's existing passive thread id, so per-response
  provider-usage-observation/1 facts join by threadId.
- requestedModel = the launch result's resolvedModel; producerStream carries the role.
- The item comes only from the brief's first line
  `fsgg-dispatch: item=OWNER/REPO#N role=...`; a missing or malformed marker is an
  item-less item-unattributed diagnostic, never a guess. Missing metadata is
  dispatch-identity-unavailable; a launch result still missing at retirement is a
  claude-launch-result-unavailable runtime gap.
- Deterministic contents and a per-source status (cursor "dispatch", retired "d")
  make a re-read replay identical facts and a resumed agent add turns to the same
  dispatch.

work-board and pnext-item (both roots) state the dispatch marker; manifests,
repos.lock, skills.yml work-board digest, census and skills changelog refreshed.
The utel roadmap records the design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (Refs #5171)

signature-doc-siting refuses XML doc comments in a .fs with a sibling .fsi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, lock and census (Refs #5171)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ease-notes line, regenerated manifests and lock (Refs #5171)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@EHotwagner

Copy link
Copy Markdown
Contributor Author

Root/fdev: independent review APPROVED a13dcffc.

  • Receiver: compatible with existing kinds only. Idempotent through the batch and fact replay.
  • Privacy: no brief text reaches the store. A real-transcript dry run gave 2 attributed and 4 missing-marker.
  • Tests: 9/9 CLI and 20/20 Core.

Fix now

  • F1: put the reason in the unattributed diagnostic's identity. Otherwise a producer-state reset followed by a re-read with a different reason quarantines the whole batch, including its usage facts.
  • F5: add a prompt-unavailable test.
  • F3: doc note that trailing whitespace on the marker is trimmed.

Follow-up: F2, foreground-subagent admission across a long quiet run, is #5198.

Accepted: F4 (meta race unlikely), and F6: mixed codex/Claude items keep their budget population open, as declared.

F7: the drive-board wrappers contain no brief text, so there is nothing to edit; removed from #5185's scope.

Version: the 0.114.2 candidate (55a24ab6) predates this PR, so it ships in 0.114.3. After 0.114.2 publishes, take the next coherent bump as kit coherence requires.

Refs #5171

…ilable and reset controls (Refs #5171)

Root review of a13dcff:
- F1: the item-unattributed diagnostic identity now ends in its reason, so a re-read after a
  producer-state reset that finds a different reason adds a fact instead of an identity conflict
  that would quarantine the batch and its usage. Control: a reset producer state with a new reason
  is accepted with both gaps and both usage observations; without the reason the receiver rejects it.
- F5: test for item-unattributed:prompt-unavailable (opening record is not the user prompt).
- F3: the roadmap notes that trailing whitespace on the marker line is trimmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5171 (Refs #5171)

0.114.2 was cut from 55a24ab before this PR and is published, so the packaged pnext-item change
moves the coherent set 0.114.2 -> 0.114.3 (Directory.Build.props, source preparation only). The CLI
PackageReleaseNotes carry a 0.114.3 heading with the subagent dispatch attribution and the marker
guidance; driver manifest, census digest and skills changelog refreshed. Picks up #5201 (FakeClock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@EHotwagner

Copy link
Copy Markdown
Contributor Author

Root/fdev delta check since the approved a13dcffc.

  • a45dd7a8: F1 puts the reason in the unattributed diagnostic identity (a one-line change in SkillTelemetryAdapter.fs). New tests cover F1's reset scenario (accepted, usage kept) and F5's prompt-unavailable. F3 adds the doc note.
  • 4daf0515: main merged (including tests: one injected FakeClock for the tend fakes (Refs #5200) #5201) and the coherent set moved to 0.114.3. Manifests and the census are regenerated, and kit pr-arm reads 0.114.3 is ahead of the newest published (0.114.2).
  • CI: 71 pass, closingIssuesReferences is [].

The approval carries over. Merging. Release 0.114.3 follows, then installed proof of the live-store join. Refs #5171

@EHotwagner
EHotwagner merged commit 5019a9d into main Nov 1, 2026
74 checks passed
@EHotwagner
EHotwagner deleted the routine/claude-subagent-dispatch-attribution branch November 1, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant