Skip to content

sessions: fold subagent runs into PR attribution#791

Merged
iamtoruk merged 6 commits into
mainfrom
feat/subagent-pr-attribution
Jul 21, 2026
Merged

sessions: fold subagent runs into PR attribution#791
iamtoruk merged 6 commits into
mainfrom
feat/subagent-pr-attribution

Conversation

@iamtoruk

@iamtoruk iamtoruk commented Jul 21, 2026

Copy link
Copy Markdown
Member

Why

Subagent (sidechain) session cost never reaches the by-PR view: each sidechain transcript becomes a standalone session with no prLinks, so it is dropped from the by-PR aggregation. A session orchestrated on one model with implementation lanes on another therefore shows only the parent model on every PR row, and the by-PR totals understate real spend.

What

Fold each sidechain session's cost, calls, per-model cost, and per-category cost into the PR its launching parent turn was working on, so subagent spend inherits that PR under the existing turn-level attribution. Children remain standalone rows in the sessions list; each is counted exactly once.

Attribution model

resolveSubagentAttribution resolves every subagent, once, against its parent's UNFILTERED turn data. buildPrAttribution produces PR rows and totals in a single pass (so aggregateByPr and prLinkedTotals never disagree; the payload builder and CLI call it once), folding each parent key exactly once so duplicate parents cannot double-count shared children.

Resolution of a folded subtree to a PR:

  1. Spawn result (toolUseResult.agentId) pairs the child's agent id with the Agent/Task tool_use id that launched it; each spawn id maps to the PR active at its emitting turn over the FULL (pre-slice) turn list, so a pre-range spawn still yields the right PR.
  2. Otherwise the child's first-activity epoch is bucketed into the parent's turn PR carry.
  3. Otherwise unattributed, or unlinked (nothing) after the parent's last turn. An AMBIGUOUSLY-paired child that lands just after the last turn folds within a 30 minute grace window; a truly-absent pairing gets no grace.

Correctness rules:

  • Mutual exclusion: a child that referenced its OWN PR attributes standalone and is never folded.
  • Global linkage keyed by provider + sessionId (NUL-delimited). A key carried by more than one DISTINCT record is ambiguous, so its child/subtree folds into NEITHER; identical duplicates fold once. The record fingerprint is a recursive canonical serialization of the COMPLETE fold-determining state -- session linkage and, per turn in sequence, timestamp/prRefs/cost/calls/savings/per-model cost -- with object keys sorted and set-semantic arrays sorted (so array order never creates false ambiguity, and a differently-timed switch turn is distinguished). Deterministic across input order.
  • Recursion aggregates a child plus its non-self-linking descendants with ONE claimed-set spanning all direct children; a diamond folds once and a cycle terminates.
  • Fold anchors (PR-linked parents kept only for attribution) live in ProjectSummary.subagentAnchors, never in sessions; a folded row takes its span from the child.

Filters (menubar / dashboard)

The project-rebuilding filters carry subagentAnchors through; a date filter CONVERTS a spawn parent whose in-range turns are all filtered out into an anchor. Rebuilt sessions retain their PR + subagent-linkage metadata. prRefsAtRangeStart is RECOMPUTED at the new slice boundary by replaying the original full turn sequence (order-independent, with a deterministic same-millisecond tie-break). The day filter additionally seeds EACH selected day's first ref-less turn from the full sequence up to that day's start, so a PR switch on an UNSELECTED day between two selected days carries correctly -- contiguous and non-contiguous selections are both exact. An anchor is dropped only when a surviving session shares its full provider-aware, fingerprint-qualified identity (a proven duplicate), so a different-provider or different-record same-id session does not wrongly drop it.

Cache

CACHE_VERSION 6 -> 7 (v6 never shipped; combined v5 -> v7 bump). Adoption migrates EVERY prior versioned file oldest-to-newest and MERGES per source path (newer wins), so a sparse newer file cannot mask older-only expired-PR orphans; an invariant note requires the list to cover every prior version on disk. New cache fields (spawnToolUseIds, parentSessionId, agentSpawnLinks, ambiguousSpawnAgentIds) are threaded through the validator and the append/compact paths like prRefs, with an append-vs-cold parity test.

Footer / payload

Totals include folded subagent spend, so CLI and app footers read "across N PR-linked session(s) + M folded-in subagent runs"; the payload gains an additive subagentSessions count (folded subtrees). distinctCost covers folded spend, documented in the payload type. attributedCost + unattributedCost === distinctCost holds and rows are summable.

Verification

  • tsc (root + app), full root suite, full app suite green. The one deterministic root failure (cli-durable-totals provider-parity) is pre-existing on main and unrelated; a couple of CLI integration tests that scan the live environment flake under parallel contention and pass 3/3 in isolation.
  • Tests span all five review rounds, including: complete-fingerprint distinctness on a per-turn timestamp difference and non-ambiguity on set-array order (both input orders); the deterministic same-millisecond seed tie-break; per-day seeding across a non-contiguous gap plus a contiguous control; provider-aware fingerprint-qualified anchor dedupe (different-provider kept, identical dropped, different-record kept and folds neither); mutual exclusion, nested recursion, diamond dedup, conflicting-vs-identical duplicates, id-collision ambiguity, the async edge, pre-range spawn resolution, out-of-range anchor parents, the grace window, cache adoption merges, and an end-to-end day-filter fold.
  • Every fix in every round was mutation-verified: the test fails on the buggy variant, passes after the fix.
  • Fresh real-data drives re-prove, to the cent, that parent-only cost plus folded-children cost equals the folded total (no double-count), that attributed + unattributed reconciles to cost, that rows sum to attributedCost, and that subagentSessions equals the folded subtree count -- for a lifetime scan, a single-day slice (anchors created, subagents fold through the filter), and a NON-CONTIGUOUS day selection.

@iamtoruk
iamtoruk force-pushed the feat/subagent-pr-attribution branch from 6c1c2da to ea5d518 Compare July 21, 2026 00:54
@iamtoruk
iamtoruk changed the base branch from feat/pr-turn-attribution to main July 21, 2026 03:44
reviewer added 6 commits July 21, 2026 05:46
Sidechain (subagent) session cost never reached the by-PR view, so a
session orchestrated on one model with subagent lanes on another showed
only the parent model on every PR row. Fold each sidechain's cost, calls,
models, and categories into the parent turn that spawned it, so it
inherits that turn's PR set under the existing turn-level state machine.

Linkage, in priority order: the spawn result's toolUseResult.agentId
pairs the child's agent id with the Agent/Task tool_use id that launched
it (recorded per turn), which is the true launch point and wins even when
the child's first activity landed during a later turn; else the child's
first-activity timestamp is bucketed into the containing turn span; else
the child folds into the parent's unattributed spend. Children of parents
that referenced no PR, and orphans whose parent is absent from the scan,
contribute nothing, unchanged.

Cache v6 to v7 (neither shipped, so one combined bump from v5): per-turn
spawnToolUseIds, per-file parentSessionId and agentSpawnLinks; the
validator and the append/compact paths thread them like prRefs. By-PR
footers now count parents plus folded subagent runs and the payload gains
an additive subagentSessions field. distinctCost now includes folded
subagent spend, documented in the payload comment.
Rework child attribution to resolve each subagent to the PR its launching
turn was working on, using the parent's UNFILTERED turn data, and enforce
that every dollar is counted exactly once.

- Mutual exclusion: a child that referenced its own PR attributes
  standalone and is never folded; a child with no links is folded only.
  Fixes a double-charge where a self-linking child was both folded and
  self-attributed.
- Recursion: a fold aggregates a child plus its non-self-linking
  descendants (depth-first, cycle-guarded), so grandchildren spawned by
  subagents reach the PR report.
- Global linkage: the subagent index keys by parentSessionId alone
  (UUIDs are globally unique), so a child whose worktree resolves to a
  different project still links.
- Date-range correctness: spawn-to-PR sets are built at assembly from the
  full turn list, so a spawn in a pre-range turn attributes to the right
  PR; a PR-linked parent whose own turns fall out of range is kept as a
  0-cost fold anchor so its in-range child is not lost.
- Timestamp fallback compares epoch ms (mixed UTC offsets order right) and
  is end-bounded: a child active after the parent's last turn is unlinked
  (contributes nothing), matching orphan semantics.
- Cache adoption tries the newest prior versioned file (v6 then v5) so the
  preceding build's expired-PR history survives the v7 bump; an invariant
  note requires the list to cover every version that can exist on disk.
- Spawn-result pairing matches the tool_result block that carries the
  agentId, not the first block, when a record batches several results.
- resolveSubagentAttribution is computed once and shared by aggregateByPr
  and prLinkedTotals.

subagentSessions now counts folded subtrees (children plus descendants).
Verified on real data: attributed + unattributed reconciles to cost, and
parent-only cost plus folded-children cost equals the folded total to the
cent (no double-count).
Address a second adversarial review of the new machinery.

- ID collision: parents and a child's parent reference are keyed by
  provider + sessionId, not bare sessionId. When two distinct parents
  still share a key (true duplicate/imported data), the child folds into
  NEITHER (deterministic skip, stays standalone): correctness over
  coverage.
- Recursion dedup is global: one claimed-set spans all of a parent's
  direct children, so a descendant reachable through two paths (a diamond
  or duplicate id) folds exactly once and a parent-link cycle terminates.
- Cache adoption migrates every prior version oldest-to-newest and MERGES
  per source path (newer wins per entry), so a sparse or partial newer
  file no longer masks older-only expired-PR orphans.
- Fold anchors (0-cost PR-linked parents kept only for attribution) live
  in a new ProjectSummary.subagentAnchors, never in `sessions`, so they no
  longer contaminate session counts, averages, or any per-session report.
  Folded PR rows take their date span from the contributing child activity
  rather than the anchor's empty timestamps.
- One-pass buildPrAttribution computes rows and totals together; the
  payload builder and CLI call it once. Drops the identity-keyed
  memoization, which could return stale folds if the array was mutated.
- Ambiguous multi-block spawn-result pairing leaves the spawn link unset
  on purpose; the child then folds via the timestamp fallback rather than
  pairing with the wrong id or disappearing.

Every fix is mutation-verified. A fresh real-data drive re-proves the
no-double-count identity to the cent (parent-only cost plus folded cost
equals the folded total) and that the PR rows sum to attributedCost.
Third adversarial review pass.

- Ambiguity counts ALL candidate parents (and anchors) sharing a
  provider+sessionId key, not just PR-bearing ones, and uses a
  per-record fingerprint: a key carried by more than one DISTINCT record
  folds its child/subtree into NEITHER (identical duplicates still fold
  once). This unifies the parent-collision and duplicate-descendant rules
  and is deterministic across input order.
- Project-rebuilding filters (by day, by date range, by config source)
  now carry subagentAnchors through, and a date filter CONVERTS a spawn
  parent whose in-range turns are all filtered out into an anchor so a
  surviving in-range child still folds. Rebuilt sessions also keep their
  PR + subagent-linkage metadata (prLinks, parentSessionId, spawnPrSets,
  ...), which buildSessionSummary otherwise drops, so by-PR and folding
  work on a filtered slice (menubar/dashboard flow).
- Fold anchors leave ProjectSummary.sessions entirely and folded PR rows
  take their span from the child, so 0-cost anchors never touch session
  counts or averages.
- Ambiguous spawn pairing (parent named the agent but its exact launching
  tool_use could not be paired) is recorded per parent; a late child of
  such a pairing folds to the parent's last turn within a 30 minute grace
  window, else stays unlinked. A truly-absent pairing gets no grace.
- Row session key is NUL-delimited and provider-prefixed, so a project
  name or session id containing a space no longer collides and
  undercounts distinct sessions.

Every fix mutation-verified. A fresh real-data drive re-proves the
no-double-count identity to the cent, and a day-filtered drive proves the
filter fix end to end (anchors created, subagents fold, identity holds).
Fourth adversarial review pass.

- sessionFingerprint now covers the COMPLETE linkage-relevant payload,
  not just headline stats: a canonical (sorted-key) serialization of
  agentSpawnLinks, spawnPrSets, prRefsAtRangeStart, ambiguousSpawnAgentIds,
  parent/agent identity, and the per-turn prRefs timeline. Two records
  that share an id and headline stats but map the child to different
  spawns/PRs now fingerprint DISTINCT, so the ambiguity rule fires and
  they fold into neither, deterministically rather than order-dependent
  first-wins.
- A date/day filter recomputes prRefsAtRangeStart at the new slice
  boundary by replaying the original full turn sequence, instead of
  copying the wide range's value. A PR switch between the wide start and
  the slice start (July 1 A, July 10 B, slice July 20) now carries B, not
  a stale A; a turn exactly on the boundary stays in-slice and applies its
  own refs. The recompute selects by timestamp, so it is order-independent.
  Non-contiguous day selections are documented as treated contiguous from
  the earliest selected day (a single session-level seed cannot represent
  multiple segments; the menubar selection is a single day or a run).
- The anchor-carry path drops an anchor that duplicates a surviving
  session id, so malformed merged input cannot double-count.

Also: rebuilt filtered sessions were losing their PR/subagent-linkage
metadata (a child its parentSessionId, a parent its prLinks), which
carryLinkageFields now restores, so by-PR and folding work on any filtered
slice.

Every fix mutation-verified. A fresh real-data drive re-proves the
no-double-count identity and reconciliation to the cent for both a
lifetime scan and a day-filtered slice (anchors created, subagents fold
through the filter).
Fifth adversarial review pass; closed-form fixes.

- sessionFingerprint serializes the COMPLETE fold-determining state via a
  real recursive canonical encoder: session-level linkage AND, per turn in
  sequence, timestamp, prRefs, cost, calls, savings, and per-model cost.
  Object keys are sorted recursively and set-semantic arrays (PR-ref lists,
  ambiguous ids, spawnPrSets values) are sorted, while the turn list keeps
  order; the structure is emitted through JSON.stringify (no delimiter
  concatenation). Two same-id parents that differ only in a turn timestamp
  now fingerprint DISTINCT (fold neither), and records differing only in
  set-array order fingerprint EQUAL (no false ambiguity).
- recomputeRangeStartPrRefs breaks an exact-same-millisecond tie
  deterministically by the lexicographically-last sorted-ref key, so the
  recomputed seed is stable regardless of turn order.
- A day filter seeds EACH selected day's first ref-less turn by replaying
  the original full turn sequence up to that day's start (per-day seeding),
  so a PR switch on an UNSELECTED day between two selected days carries to
  the later day. Contiguous and non-contiguous selections are both correct.
- The anchor dedupe drops an anchor only when a surviving session shares
  the full provider-aware, fingerprint-qualified identity (a proven
  duplicate): a different-provider or different-record same-id session no
  longer wrongly drops the anchor.

Also fixed a double-count the fingerprint test exposed: two duplicate
parent sessions share a key and the SAME resolved children, so folding is
now done once per parent key.

Every fix mutation-verified. Fresh real-data drives (lifetime, single-day,
and a NON-CONTIGUOUS day selection) re-prove no-double-count and
reconciliation to the cent.
@iamtoruk
iamtoruk force-pushed the feat/subagent-pr-attribution branch from de00ecd to 2803040 Compare July 21, 2026 03:47
@iamtoruk
iamtoruk merged commit 8e1caae into main Jul 21, 2026
3 checks passed
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