Skip to content

Commit e51f68b

Browse files
authored
Fix nested subagent hierarchy: re-parent depth>=2 subagents to their spawner (#1320)
Fixes #1246. Supersedes #1247. Claude Code stores nested subagents in one flat directory, so path-based parsing can attach deeper children to the top-level session instead of the session that actually spawned them. This change reconciles effective parents from recorded spawn edges, which also puts nested usage rollups under the right session. Conflicting edges resolve deterministically using normalized start times and immutable parser provenance. Before exclusions or message replacement can erase an edge, sync persists the affected child IDs in SQLite; repair and queue cleanup then commit together. Failed writes or repairs remain retryable across later syncs, process restarts, and archive rebuilds. SQLite migration and provenance backfill are atomic. PostgreSQL now mirrors parser provenance through schema compatibility, reads, writes, conflict checks, and fingerprints, while a v2 backfill marker makes existing mirrors republish unchanged legacy rows once. Global work stays bounded by spawn edges, and incremental work stays scoped to affected sessions. One conservative limit remains: if an edge disappears while the recorded parent still exists and no competing edge remains, the historical parent is retained because the archive cannot prove whether it came from the removed edge or parser provenance. Broader copy and fork provenance remains tracked in #1250. Reviewers should start with `internal/db/sessions.go`, then follow sync orchestration in `internal/sync/engine.go` and PostgreSQL propagation in `internal/postgres/`. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent eabb9be commit e51f68b

17 files changed

Lines changed: 2933 additions & 85 deletions

internal/db/db.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,6 +1763,14 @@ func schemaColumnMigrations() []schemaColumnMigration {
17631763
"sessions", "display_name",
17641764
"ALTER TABLE sessions ADD COLUMN display_name TEXT",
17651765
},
1766+
{
1767+
// Preserve the current parent exactly once when the private parser
1768+
// provenance column is introduced. Running the UPDATE on every open
1769+
// would let a later linker-derived effective parent overwrite it.
1770+
"sessions", "parser_parent_session_id",
1771+
"ALTER TABLE sessions ADD COLUMN parser_parent_session_id TEXT;" +
1772+
" UPDATE sessions SET parser_parent_session_id = parent_session_id",
1773+
},
17661774
{
17671775
"sessions", "session_name",
17681776
"ALTER TABLE sessions ADD COLUMN session_name TEXT",
@@ -2160,11 +2168,26 @@ func schemaColumnMigrations() []schemaColumnMigration {
21602168
}
21612169
}
21622170

2163-
func applySchemaColumnMigrations(
2164-
queryRow func(string, ...any) rowScanner,
2165-
exec func(string, ...any) (sql.Result, error),
2166-
) error {
2167-
return applyColumnMigrations(schemaColumnMigrations(), queryRow, exec)
2171+
func applySchemaColumnMigrations(w *writerHandle) error {
2172+
tx, err := w.BeginTx(context.Background(), nil)
2173+
if err != nil {
2174+
return fmt.Errorf("starting column migration transaction: %w", err)
2175+
}
2176+
defer func() { _ = tx.Rollback() }()
2177+
2178+
if err := applyColumnMigrations(
2179+
schemaColumnMigrations(),
2180+
func(query string, args ...any) rowScanner {
2181+
return tx.QueryRow(query, args...)
2182+
},
2183+
tx.Exec,
2184+
); err != nil {
2185+
return err
2186+
}
2187+
if err := tx.Commit(); err != nil {
2188+
return fmt.Errorf("committing column migrations: %w", err)
2189+
}
2190+
return nil
21682191
}
21692192

21702193
func applyColumnMigrations(
@@ -2411,7 +2434,7 @@ func (db *DB) migrateColumns() error {
24112434
if _, err := w.Exec(artifactSessionQueueTriggerDropsSQL); err != nil {
24122435
return fmt.Errorf("dropping artifact session queue triggers: %w", err)
24132436
}
2414-
if err := applySchemaColumnMigrations(w.QueryRow, w.Exec); err != nil {
2437+
if err := applySchemaColumnMigrations(w); err != nil {
24152438
return err
24162439
}
24172440
if _, err := w.Exec(artifactSessionQueueTriggerCreatesSQL); err != nil {

internal/db/db_test.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,35 @@ func TestSessionParentSessionID(t *testing.T) {
12851285
})
12861286
}
12871287

1288+
func TestUpsertSessionRefreshesParserParentSessionID(t *testing.T) {
1289+
d := testDB(t)
1290+
s := Session{
1291+
ID: "kid",
1292+
Project: "proj",
1293+
Machine: defaultMachine,
1294+
Agent: defaultAgent,
1295+
ParentSessionID: Ptr("first-parent"),
1296+
}
1297+
require.NoError(t, d.UpsertSession(s), "insert session")
1298+
1299+
assertParserParent := func(want string) {
1300+
t.Helper()
1301+
var got sql.NullString
1302+
err := d.getReader().QueryRow(
1303+
`SELECT parser_parent_session_id FROM sessions WHERE id = ?`,
1304+
"kid",
1305+
).Scan(&got)
1306+
require.NoError(t, err, "query parser parent")
1307+
require.True(t, got.Valid, "parser parent must be set")
1308+
assert.Equal(t, want, got.String, "parser parent")
1309+
}
1310+
1311+
assertParserParent("first-parent")
1312+
s.ParentSessionID = Ptr("second-parent")
1313+
require.NoError(t, d.UpsertSession(s), "update session")
1314+
assertParserParent("second-parent")
1315+
}
1316+
12881317
func TestGetChildSessions(t *testing.T) {
12891318
d := testDB(t)
12901319

@@ -5588,7 +5617,7 @@ func TestCopySyncStateFrom_NoSourceTable(t *testing.T) {
55885617
assert.Equal(t, "marker-123", got)
55895618
}
55905619

5591-
func TestCopySyncStateFrom_OnlyCopiesDurablePGKeys(t *testing.T) {
5620+
func TestCopySyncStateFrom_OnlyCopiesDurableKeys(t *testing.T) {
55925621
dir := t.TempDir()
55935622

55945623
srcPath := filepath.Join(dir, "src.db")
@@ -5601,6 +5630,11 @@ func TestCopySyncStateFrom_OnlyCopiesDurablePGKeys(t *testing.T) {
56015630
"seed source started")
56025631
require.NoError(t, srcDB.SetSyncState("last_sync_finished_at", "old-finish"),
56035632
"seed source finished")
5633+
require.NoError(t, srcDB.QueueSubagentParentRepairs([]string{"queued-child"}),
5634+
"seed durable hierarchy repair")
5635+
require.NoError(t, srcDB.QueueSubagentParentCleanupRepairs(
5636+
[]string{"queued-former-child"},
5637+
), "seed durable hierarchy cleanup")
56045638
require.NoError(t, srcDB.UpsertSession(Session{
56055639
ID: "queued-session", Project: "p", Machine: "local", Agent: "claude",
56065640
}), "seed source queued session")
@@ -5629,6 +5663,21 @@ func TestCopySyncStateFrom_OnlyCopiesDurablePGKeys(t *testing.T) {
56295663
assert.Contains(t, artifactExportQueueIDs(t, dstDB), "queued-session",
56305664
"artifact export queue rows must survive the copy")
56315665

5666+
var queuedRepairs int
5667+
require.NoError(t, dstDB.Reader().QueryRow(`
5668+
SELECT count(*) FROM subagent_parent_repair_queue
5669+
WHERE session_id = 'queued-child'`,
5670+
).Scan(&queuedRepairs), "query copied subagent repair queue")
5671+
assert.Equal(t, 1, queuedRepairs,
5672+
"pending hierarchy repairs must survive an archive rebuild")
5673+
var queuedCleanups int
5674+
require.NoError(t, dstDB.Reader().QueryRow(`
5675+
SELECT count(*) FROM subagent_parent_cleanup_queue
5676+
WHERE session_id = 'queued-former-child'`,
5677+
).Scan(&queuedCleanups), "query copied subagent cleanup queue")
5678+
assert.Equal(t, 1, queuedCleanups,
5679+
"pending destructive cleanup intent must survive an archive rebuild")
5680+
56325681
gotStarted, err := dstDB.GetSyncState("last_sync_started_at")
56335682
require.NoError(t, err, "GetSyncState last_sync_started_at")
56345683
assert.Equal(t, "new-start", gotStarted)

internal/db/legacy_schema_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,96 @@ func TestOpenLegacySchemasPreservesArchiveAndRequestsResync(t *testing.T) {
227227
}
228228
}
229229

230+
func TestParserParentSessionIDMigrationBackfillsCurrentParent(t *testing.T) {
231+
path := filepath.Join(t.TempDir(), "legacy.db")
232+
conn, err := sql.Open("sqlite3", makeDSN(path, false))
233+
require.NoError(t, err)
234+
conn.SetMaxOpenConns(1)
235+
236+
_, err = conn.Exec(v06LegacySchema)
237+
require.NoError(t, err, "create legacy schema")
238+
_, err = conn.Exec(`
239+
INSERT INTO sessions (
240+
id, project, machine, agent, parent_session_id
241+
) VALUES (
242+
'kid', 'project-a', 'local', 'claude', 'parsed-parent'
243+
)`)
244+
require.NoError(t, err, "insert legacy child")
245+
_, err = conn.Exec(fmt.Sprintf(
246+
"PRAGMA user_version = %d", dataVersion,
247+
))
248+
require.NoError(t, err, "set current data version")
249+
require.NoError(t, conn.Close(), "close legacy database")
250+
251+
d, err := Open(path)
252+
require.NoError(t, err, "open migrated database")
253+
defer d.Close()
254+
255+
var got sql.NullString
256+
err = d.getReader().QueryRow(`
257+
SELECT parser_parent_session_id FROM sessions WHERE id = 'kid'
258+
`).Scan(&got)
259+
require.NoError(t, err, "query migrated parser parent")
260+
require.True(t, got.Valid, "migrated parser parent must be set")
261+
assert.Equal(t, "parsed-parent", got.String, "migrated parser parent")
262+
}
263+
264+
func TestParserParentSessionIDMigrationRollsBackWhenBackfillFails(t *testing.T) {
265+
path := filepath.Join(t.TempDir(), "legacy.db")
266+
conn, err := sql.Open("sqlite3", makeDSN(path, false))
267+
require.NoError(t, err)
268+
conn.SetMaxOpenConns(1)
269+
270+
_, err = conn.Exec(v06LegacySchema)
271+
require.NoError(t, err, "create legacy schema")
272+
_, err = conn.Exec(`
273+
INSERT INTO sessions (
274+
id, project, machine, agent, parent_session_id
275+
) VALUES (
276+
'kid', 'project-a', 'local', 'claude', 'parsed-parent'
277+
);
278+
CREATE TRIGGER fail_parser_parent_backfill
279+
BEFORE UPDATE OF parser_parent_session_id ON sessions BEGIN
280+
SELECT RAISE(ABORT, 'injected parser parent backfill failure');
281+
END;`)
282+
require.NoError(t, err, "prepare failing legacy migration")
283+
_, err = conn.Exec(fmt.Sprintf(
284+
"PRAGMA user_version = %d", dataVersion,
285+
))
286+
require.NoError(t, err, "set current data version")
287+
require.NoError(t, conn.Close(), "close legacy database")
288+
289+
d, err := Open(path)
290+
require.ErrorContains(t, err, "injected parser parent backfill failure")
291+
require.Nil(t, d)
292+
293+
conn, err = sql.Open("sqlite3", makeDSN(path, false))
294+
require.NoError(t, err, "reopen failed migration")
295+
conn.SetMaxOpenConns(1)
296+
var columnCount int
297+
err = conn.QueryRow(`
298+
SELECT count(*) FROM pragma_table_info('sessions')
299+
WHERE name = 'parser_parent_session_id'
300+
`).Scan(&columnCount)
301+
require.NoError(t, err, "inspect schema after failed migration")
302+
assert.Zero(t, columnCount, "failed migration must roll back added column")
303+
_, err = conn.Exec(`DROP TRIGGER fail_parser_parent_backfill`)
304+
require.NoError(t, err, "remove injected migration failure")
305+
require.NoError(t, conn.Close(), "close failed migration database")
306+
307+
d, err = Open(path)
308+
require.NoError(t, err, "retry migration")
309+
defer d.Close()
310+
311+
var got sql.NullString
312+
err = d.getReader().QueryRow(`
313+
SELECT parser_parent_session_id FROM sessions WHERE id = 'kid'
314+
`).Scan(&got)
315+
require.NoError(t, err, "query retried parser parent")
316+
require.True(t, got.Valid, "retried parser parent must be set")
317+
assert.Equal(t, "parsed-parent", got.String, "retried parser parent")
318+
}
319+
230320
func TestLegacySchemaAddsArtifactImportAuthorityNonDestructively(t *testing.T) {
231321
path := filepath.Join(t.TempDir(), "legacy.db")
232322
conn, err := sql.Open("sqlite3", makeDSN(path, false))

0 commit comments

Comments
 (0)