Skip to content

Commit eac1897

Browse files
committed
fix(sync): queue attempted hierarchy repairs
A full session write persists parser-derived parent provenance before its later stages complete. If one of those stages fails, outgoing-edge discovery cannot find the attempted session through an incoming spawn edge, so its effective parent can remain stale. Durably seed repair with each session before starting its write so deferred reconciliation covers both the session itself and any children its committed messages introduce.
1 parent 2400ce0 commit eac1897

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

internal/sync/engine.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13480,6 +13480,18 @@ func (e *Engine) SyncSingleSessionContext(
1348013480
needsRetry: res.needsRetryForSession(pr.Session.ID),
1348113481
forceReplace: res.forceReplace,
1348213482
}
13483+
// The session upsert commits parser-derived parent provenance before
13484+
// the later content, usage, and completion stages. Queue the attempted
13485+
// session itself first so a failure after that upsert still re-resolves
13486+
// its incoming spawn edges in the deferred repair pass.
13487+
if err := e.db.QueueSubagentParentRepairs(
13488+
[]string{resultIDs[i]},
13489+
); err != nil {
13490+
return fmt.Errorf(
13491+
"queue attempted session parent repair: %w", err,
13492+
)
13493+
}
13494+
repairQueued = true
1348313495
writeErr := e.writeSessionFull(write)
1348413496
// Full-write stages commit independently. Message content (and a new
1348513497
// spawn edge) can persist even when a later usage, data-version, or

internal/sync/provider_process_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,73 @@ func TestSyncSingleSessionPartialFullWritesQueueNewChild(t *testing.T) {
537537
}
538538
}
539539

540+
func TestSyncSingleSessionPartialFullWriteRepairsAttemptedSession(t *testing.T) {
541+
root := t.TempDir()
542+
sourcePath, fingerprint := writeProcessProviderSource(
543+
t, root, "partial-parent-write.jsonl",
544+
)
545+
child := processFixtureResult(
546+
"cowork:child", parser.AgentCowork, "fixture-project",
547+
sourcePath, fingerprint,
548+
)
549+
child.Session.ParentSessionID = "cowork:path-parent"
550+
child.Session.RelationshipType = parser.RelSubagent
551+
provider := newProcessFixtureProvider(
552+
processFixtureSource(sourcePath), fingerprint,
553+
parser.ParseOutcome{
554+
Results: []parser.ParseResultOutcome{{
555+
Result: child, DataVersion: parser.DataVersionCurrent,
556+
}},
557+
ResultSetComplete: true,
558+
ForceReplace: true,
559+
},
560+
)
561+
engine := newProcessFixtureEngine(t, root, provider)
562+
database := engine.db
563+
actualParent := "cowork:spawner"
564+
started := "2026-01-01T00:00:00Z"
565+
require.NoError(t, database.UpsertSession(db.Session{
566+
ID: actualParent, Agent: string(parser.AgentCowork),
567+
Project: "fixture-project", Machine: "devbox", StartedAt: &started,
568+
MessageCount: 1,
569+
}))
570+
require.NoError(t, database.UpsertSession(db.Session{
571+
ID: "cowork:child", Agent: string(parser.AgentCowork),
572+
Project: "fixture-project", Machine: "devbox",
573+
ParentSessionID: &actualParent, RelationshipType: string(parser.RelSubagent),
574+
}))
575+
require.NoError(t, database.InsertMessages([]db.Message{{
576+
SessionID: actualParent, Ordinal: 0, Role: string(parser.RoleAssistant),
577+
Content: "spawn child", HasToolUse: true,
578+
ToolCalls: []db.ToolCall{{
579+
ToolUseID: "spawn-child", ToolName: "Agent",
580+
SubagentSessionID: "cowork:child",
581+
}},
582+
}}))
583+
584+
raw, err := sql.Open("sqlite3", database.Path())
585+
require.NoError(t, err)
586+
t.Cleanup(func() { require.NoError(t, raw.Close()) })
587+
_, err = raw.Exec(fmt.Sprintf(`
588+
CREATE TRIGGER fail_child_write_completion
589+
BEFORE UPDATE OF data_version ON sessions
590+
WHEN NEW.id = 'cowork:child' AND NEW.data_version = %d
591+
BEGIN
592+
SELECT RAISE(FAIL, 'injected child completion failure');
593+
END`, db.CurrentDataVersion()))
594+
require.NoError(t, err)
595+
596+
syncErr := engine.SyncSingleSession("cowork:child")
597+
598+
require.ErrorContains(t, syncErr, "injected child completion failure")
599+
stored, err := database.GetSession(t.Context(), "cowork:child")
600+
require.NoError(t, err)
601+
require.NotNil(t, stored)
602+
require.NotNil(t, stored.ParentSessionID)
603+
assert.Equal(t, actualParent, *stored.ParentSessionID,
604+
"deferred repair must reconcile the partially written session itself")
605+
}
606+
540607
func TestProcessFileProviderAuthoritativeSuppressesUncleanSkipCache(t *testing.T) {
541608

542609
root := t.TempDir()

0 commit comments

Comments
 (0)