Fix nested subagent hierarchy: re-parent depth≥2 subagents to their spawner - #1247
Fix nested subagent hierarchy: re-parent depth≥2 subagents to their spawner#1247nmishaan wants to merge 9 commits into
Conversation
…spawner Nested Claude Code subagents (main -> orchestrator -> grandchild) all live in the same flat <main-session>/subagents/ dir, so claudeCompanionParentSessionID derives parent_session_id = the main session for every depth and tags each 'subagent'. LinkSubagentSessions then skipped them via "WHERE relationship_type != 'subagent'", so the authoritative tool_calls.subagent_session_id edge never re-parented depth>=2 sessions: the hierarchy stayed flat, breaking /children results and per-session usage rollups. Drop the relationship_type guard and re-point parent_session_id from the tool_calls edge whenever it differs (null-safe IS NOT), which still covers the existing continuation -> subagent upgrade. Add TestLinkSubagentSessionsReParentsNestedGrandchild (fails before, passes after). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
roborev: Combined Review (
|
roborev review flagged that LinkSubagentSessions sets both parent_session_id and relationship_type='subagent', but the "parent differs" predicate skipped rows whose parent already matched the tool-call spawner yet were misclassified as continuation/fork/empty -- leaving relationship_type un-upgraded and the session grouped wrong. Gate on (relationship_type != 'subagent' OR parent differs) so the type upgrade runs even when the parent is unchanged, while still leaving already-correct subagents untouched to avoid local_modified_at churn. Add TestLinkSubagentSessionsUpgradesTypeWhenParentAlreadyMatches (parent already matches, type=continuation -> upgraded to subagent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Good catch confirmed and fixed in 9d2e0b6. You're right that The predicate is now: AND (
relationship_type != 'subagent'
OR parent_session_id IS NOT (SELECT tc.session_id FROM tool_calls tc
WHERE tc.subagent_session_id = sessions.id LIMIT 1)
)so the type upgrade fires even when the parent is unchanged, while already-correct subagents match neither branch (no (Disclosure: investigated/authored with Claude Code; changes verified locally.) |
Update the LinkSubagentSessions doc comment to cover the re-parenting of nested subagents (it previously described only the type upgrade). Add TestLinkSubagentSessionsLinksNullParentSubagent: a 'subagent'-tagged session with a NULL parent plus a spawn edge must be linked to its spawner. The test fails if the null-safe `IS NOT` is replaced with `!=` (mutation-verified), and was suggested independently by two reviewers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed a small polish commit (
All One pre-existing observation, intentionally left out of scope: the (Disclosure: iterated with Claude Code and a second-opinion review pass; all changes verified locally.) |
roborev: Combined Review (
|
roborev flagged that the bare LIMIT 1 subquery could re-parent a correctly parented subagent arbitrarily when a child is referenced by spawn edges from more than one distinct session (reachable via copied/forked history). This became reachable for already-'subagent' rows once the prior commit added the "OR parent differs" branch. Resolve the parent only from an unambiguous edge: GROUP BY subagent_session_id HAVING COUNT(DISTINCT tc.session_id) = 1, wrapped in COALESCE(..., parent_session_id) so conflicting edges preserve the current parent instead of picking one arbitrarily. This also removes the LIMIT 1 nondeterminism between the SET and WHERE evaluations. Add TestLinkSubagentSessionsPreservesParentOnConflictingEdges (mutation-verified: fails if the exactly-one-distinct-spawner guard is relaxed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
roborev: Combined Review (
|
|
Thanks — real catch. The temporal case is genuine but narrow: it needs a forked/copied edge ingested before its source's edge, and a fork derives from its source (so the source is normally parsed first, where the current Fully resolving it needs stable provenance — e.g. resolving ambiguous edges to the earliest-started spawner, or tracking source-parent state to reverse a provisional link — a fork-lineage design decision I scoped out of this depth-2 parenting fix. The core change here is a strict improvement over the previous bare- Opened #1250 to track the provenance work (ingestion-order determinism + a self-edge guard), with your suggested before/after-conflicting-edge regression test noted. Happy to send a follow-up PR against whichever resolution approach you prefer. |
|
Thanks for the investigation and for opening #1250 — agreed the broader fork/copy provenance policy is separate design work. But the ingestion-order case needs to be resolved in this PR, because it's introduced here: on The existing conflicting-edge test inserts both edges before linking, so it doesn't exercise this sequence. Please add: (1) child correctly parented, (2) insert the copied edge, link, (3) insert the real edge, link again, (4) assert the child converges to and remains under the real spawner. Concretely, resolving the edge to the earliest-started spawner (join |
roborev: Combined Review (
|
…ew fix) @mjacobs flagged that the ingestion-order case has to be resolved in this PR, since it is introduced here: on main a correctly-parented 'subagent' row can never be re-parented, but once this PR adds the "OR parent differs" branch a copied edge that is temporarily the ONLY stored edge overwrites the correct parent -- and the previous COALESCE(unambiguous_edge, parent_session_id) rule then locked that wrong parent in, because "ambiguous" resolved to "keep the current parent" and the current parent was itself provisional. The root problem was that resolution read the row's own parent_session_id, so its result depended on what earlier syncs had written. Resolve the spawner purely from the stored edges instead: order the candidate edges by the spawner's started_at and take the first. A fork derives from the session it was forked from, so it always starts later; the earliest-started candidate is the real spawner from any subset of the edges. A link written from a partial view therefore self-corrects on the next sync instead of being locked in, in either ingestion order. Two further keys keep that order total. started_at is nullable TEXT (and '' is treated as unset elsewhere in this package) and SQLite sorts NULL first, so a leading `NULLIF(ps.started_at,'') IS NULL` key pushes unknown start times last rather than letting them outrank every real one; tc.session_id then breaks the remaining ties, so a child whose candidates all lack a start time still resolves identically on every sync instead of following whichever edge SQLite visited first. The LEFT JOIN keeps an edge whose spawner has no sessions row as a last-resort candidate rather than discarding it. The resolution is shared between the SET and the WHERE as subagentSpawnerExpr, so the two cannot disagree. Tests, per the sequence requested in review: - TestLinkSubagentSessionsConvergesAcrossIngestionOrder: child correctly parented, insert the copied edge, link, insert the real edge, link again, assert the child converges to and remains under the real spawner -- and the mirror order, where a later-arriving copied edge must not steal the child. - TestLinkSubagentSessionsPrefersSpawnerWithKnownStartTime: covers the NULL / '' start-time ordering (fails without the leading IS NULL key). - TestLinkSubagentSessionsResolvesConflictingEdgesDeterministically: replaces the preserve-on-ambiguity test, asserting the id tiebreak selects the same spawner regardless of which edge was inserted first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
roborev: Combined Review (
|
…index
Two review findings, both in LinkSubagentSessions.
1. Timestamp ordering (correctness). started_at is TEXT written by
timeutil.Format, i.e. time.RFC3339Nano, which STRIPS trailing zeros from the
fractional second. Production values are therefore not fixed width: a
whole-second start is stored '2026-01-01T00:00:00Z' while a start 100ms later
is stored '2026-01-01T00:00:00.1Z'. Since '.' (0x2E) sorts before 'Z' (0x5A),
ordering the raw strings puts the LATER timestamp FIRST -- so the previous
ORDER BY NULLIF(ps.started_at,'') handed the child to the copied spawner in
exactly the case the earliest-started rule exists to resolve. It is not a
corner case: any spawner whose start lands on a whole second stores no
fractional part at all and sorts behind every fractional one.
Order by strftime('%Y-%m-%dT%H:%M:%fZ', ps.started_at) instead, which
re-renders every value as fixed-width '...T00:00:00.000Z' where lexical order
is chronological. This is the same normalization the sync_marker triggers in
db.go already use. It also subsumes the NULLIF wrapper: strftime returns NULL
for an unset, empty OR malformed started_at, so the leading IS NULL key now
sorts unparseable timestamps last too, which the previous form did not.
Resolution is millisecond-granular (SQLite's parser truncates there); closer
ties fall through to the existing tc.session_id key, so it stays total.
Added TestLinkSubagentSessionsOrdersStartTimesChronologically, building both
timestamps with timeutil.Format itself so it cannot drift from what the
parsers write, and asserting up front that the raw strings really do sort
inverted (otherwise the test would pass without the fix). Mutation-verified:
restoring the raw NULLIF ordering fails it with parent 'copied-spawner'.
2. Archive scaling (performance). Linking ran as a global UPDATE whose row set
came from EXISTS(...), which SQLite plans as a full SCAN of sessions with a
correlated probe per row -- so every watcher-driven sync did work proportional
to the whole archive even when nothing had changed. Rather than narrowing the
call sites (reconciliation has to stay global: a later-arriving edge is
written into the SPAWNER's transcript, and re-checking the child on the next
sync is what makes the ingestion-order convergence above self-correct), drive
the statement from the edges: `s.id IN (SELECT tc.subagent_session_id ...)`
seeks the idx_tool_calls_subagent partial index and then looks each child up
by primary key. Cost now tracks the number of spawn edges, not archive size.
Measured on a seeded archive, steady state (every subagent already linked, so
the UPDATE matches zero rows), holding spawn edges at 8k:
40k sessions 200k sessions growth
main 17.1 ms 102.5 ms 6.0x
EXISTS form 35.9 ms 109.4 ms 3.0x
IN form 25.1 ms 30.9 ms 1.2x
So this is not merely a recovered regression: at 200k sessions linking is now
3.3x faster than main, because main scans sessions too. (A single-pass
UPDATE..FROM with a ROW_NUMBER window was also measured -- 27.4 ms -- and
rejected: it trades 8k tiny sorts for one large materialized one and is a
bigger diff for no gain.)
Added TestLinkSubagentSessionsPlanScalesWithSpawnEdges, which asserts the
query plan rather than wall-clock time: the plan is what decides the scaling
and it is deterministic in CI, matching the existing EXPLAIN QUERY PLAN
assertions in source_path_hints_test.go. It runs at two archive sizes so it
cannot pass merely for want of statistics. Mutation-verified: swapping the IN
back to the equivalent-reading EXISTS re-introduces 'SCAN s' and fails it
while every behavioural test stays green -- which is exactly the silent
regression it guards.
The statement moved into a linkSubagentSessionsQuery const so the test can
EXPLAIN the same SQL the writer executes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed 1. Ingestion-order convergence (@mjacobs) — done in
|
| variant | 40k sessions | 200k sessions | growth |
|---|---|---|---|
main today |
17.1 ms | 102.5 ms | 6.0× |
this PR, EXISTS form |
35.9 ms | 109.4 ms | 3.0× |
this PR, IN form (shipped) |
25.1 ms | 30.9 ms | 1.2× |
So this isn't just a recovered regression — at 200k sessions linking is now 3.3× faster than main, because per-sync cost tracks the number of spawn edges rather than the size of the archive. (I also measured a single-pass UPDATE .. FROM with a ROW_NUMBER() window: 27.4 ms — it trades 8k tiny sorts for one large materialized one, so it's a bigger diff for no gain. Rejected.)
On "limit reconciliation to child IDs affected by the current sync": I deliberately didn't, because it would break the convergence in §1. A later-arriving spawn edge is written into the spawner's transcript, not the child's — re-checking a child that this sync didn't touch is exactly what lets a provisional link self-correct. Narrowing the scope to sessions written this sync reintroduces the lock-in @mjacobs asked to fix. Making the global statement index-driven gets the scaling without that trade.
TestLinkSubagentSessionsPlanScalesWithSpawnEdges asserts the query plan rather than wall-clock time — the plan is what decides the scaling and it's deterministic in CI (same approach as the existing EXPLAIN QUERY PLAN assertions in source_path_hints_test.go). It runs at two archive sizes so it can't pass merely for want of statistics. Mutation-verified: swapping the IN back to the equivalent-reading EXISTS re-introduces SCAN s and fails it while every behavioural test stays green — which is the silent regression it exists to catch.
Verification
Go 1.26.5, -tags fts5:
./internal/db— full package, green (allLinkSubagent*tests included)./internal/duckdb— full package, green, includingTestPushIncrementalMirrorsSubagentLinkBackfill, which depends on this function bumpinglocal_modified_atso the linked session re-enters the incremental mirror window./internal/sync— allSubagent|Link|Parent|Hierarchtests green
Both new tests are mutation-verified fail→pass as described above.
Two flaky-test notes, both pre-existing and outside internal/db:
- I could not get a clean full-package
internal/syncrun on either branch:TestFileChangeTimeDetectsSameStatRewritefails for me on unmodifiedmain(9e5324c) in 4 of 5 isolated runs. Looks environment-sensitive rather than related to any of this. Go Test (windows-latest)is red onmaintoo, with a different test each run —TestPushWatchProductionOwnersRetryPartialAuthoritativeBatchandTestWatcherOverflowCollapsesPathAndByteLimitsToOneFullSynconmain,TestVerifiedSourceGateRechecksAfterStatAndWatcherInvalidationon this branch. All three are ininternal/sync; this PR touches onlyinternal/db.
Happy to open a separate issue for the internal/sync flakes if that's useful — they're unrelated to this change but they do make the signal here harder to read.
(The CI workflow runs for both pushes are sitting at action_required pending maintainer approval, so the only check reporting on this PR right now is roborev.)
(Disclosure: investigated and authored with Claude Code; the timing/plan numbers above were measured locally on a seeded SQLite archive, and both new tests were mutation-verified fail→pass.)
roborev: Combined Review (
|
The session watcher re-syncs one file per change event, but SyncSingleSessionContext followed every write with the global LinkSubagentSessions pass, so per-event work enumerated all archived spawn edges instead of tracking the changed batch. Add LinkSubagentSessionsForSessions: the same resolution restricted to children reachable from the batch, via either a batch member's spawn edges (idx_tool_calls_session) or the batch member being a child itself (idx_tool_calls_subagent). Because resolution is a pure function of the stored edges and a conflicting edge always arrives through its spawner's transcript, scoping preserves ingestion-order convergence: the spawner whose edge just landed is in the batch by definition. Bulk paths (full sync, reconciliation, resync) keep the coalesced global pass. Batches chunk at half maxSQLVars since each id binds once per UNION branch.
Ranking a spawner with no usable started_at behind every dated candidate means a real spawner lacking a timestamp can lose to a dated copy. Record why that corner is accepted: the only signal that could protect it is the child's stored parent, which reintroduces the ingestion-order dependence the resolution exists to remove, and the link self-corrects once the spawner's start time becomes known.
roborev: Combined Review (
|
…nking Two gaps in the batch-scoped watcher linking: The incremental branch returned after writeIncremental without linking. An append can introduce a spawn edge (a Task tool_use whose subagent mapping lands in the same append stays on the incremental path), which left the child's hierarchy stale until the next bulk sync — the live watcher path is exactly this shape. Link scoped to the appended session. The scoped linker discovers children only through post-write edges, so a full rewrite or parser-exclusion delete that cascades an edge away left the former child unable to re-resolve to a remaining spawner. Capture the children the batch's pre-write edges reference (db.SubagentChildSessionIDs) before any destructive write and carry them into the linking batch, including on the no-results path where only exclusions ran. Both regression tests are mutation-verified: removing the incremental link call fails the append test, and dropping the pre-captured children fails the rewrite test.
roborev: Combined Review (
|
|
I can't push here anymore for some reason so opening a new PR to supersede this |
Fixes #1246.
Problem
Nested Claude Code subagents (
main -> orchestrator -> grandchild, spawnDepth >= 2) alllive in the same flat
<main-session>/subagents/directory, so path derivation assignsparent_session_id = main sessionat every depth and tags each rowrelationship_type = 'subagent'.LinkSubagentSessions()— which corrects the parentfrom the authoritative
tool_calls.subagent_session_idspawn edge — skipped those rowsbecause of its
WHERE relationship_type != 'subagent'guard. The hierarchy stayed flatpast depth 1:
GET /sessions/{orchestrator}/childrenwas empty and per-session usagerollups attributed the whole subtree to the main session.
Change
All in
internal/db/sessions.go, plus one call-site change ininternal/sync/engine.go:is not yet tagged
subagent(the pre-existing upgrade path) or when its stored parentdiffers from the spawn edge (null-safe
IS NOT). The tool-call edge records the actualspawn, so it is authoritative over the path heuristic.
subagentSpawnerExpr). Copied or forked historycan leave several sessions claiming the same child. Resolution is a pure function of
the stored edges — never of ingestion order — so a link written from a partial view
self-corrects on a later sync: candidates are ordered by start time (a fork derives
from its source, so it starts later), with
started_atnormalized viastrftimebecause raw RFC3339Nano strings do not sort chronologically when fractional seconds
are stripped. Unknown or malformed start times rank last, and the session id breaks
remaining ties.
idx_tool_calls_subagentpartial index (s.id IN (SELECT tc.subagent_session_id ...))instead of a full
sessionsscan, so bulk-sync linking cost tracks the number of spawnedges rather than archive size.
SyncSingleSessionContext— called by thesession watcher on every file change — now uses
LinkSubagentSessionsForSessions,which restricts the pass to children reachable from the sessions that sync wrote
(spawner-side and child-side branches, both index seeks). Bulk paths (full sync,
reconciliation, resync) keep their coalesced global pass. Query-plan tests pin both
cost shapes at two archive sizes.
introduce a spawn edge when the Task tool_use and its subagent mapping land in the
same append) links scoped to the appended session, and children referenced by a
batch's pre-write edges are captured (
SubagentChildSessionIDs) before rewrites orparser-exclusion deletes cascade those edges away, so a former child still
re-resolves to its remaining spawner in the same sync.
Tradeoffs and limitations
started_at, a datedcopy wins; protecting it would require reading the child's stored parent, which
reintroduces ingestion-order dependence. The link self-corrects once the spawner's
start time becomes known. Documented in
subagentSpawnerExpr.LinkSubagentSessions: provenance for conflicting fork/copy spawn edges (follow-up to #1247) #1250.
tool_callsat any depth;this fix makes
/childrenand usage rollups correct.Reviewers should start at
subagentSpawnerExprand the two linking statements ininternal/db/sessions.go, then theSyncSingleSessionContextcall site.Disclosure: investigated and written with Claude Code (AI).