Repository navigation
fix(runtime-host): one linked-child rule for every conversation copy - #5757
Conversation
A branch from any turn that retained a linked child Session always failed with "Ordinary branches cannot share linked child Session ownership with their source". The invariant is right: a branch has its own lifecycle, so it must not hold child Run/Artifact identities owned by the source revision family. But rejecting was not the only way to keep it. Side conversations already solve the same problem: they validate that every retained child is terminal, copy the child Artifacts into the new Session, and drop childSessionId/runId from the copied results. Branches now take that same snapshot path; only revisions keep sharing child ownership. The archived-tool-result gate for branches is unchanged. Refs apache#5333 Generated-by: Devin
When the Host refused a branch (session_busy or operation_unavailable),
the IPC handler rethrew the RuntimeHostOperationError. Electron IPC drops
the error class and code, so the renderer could only show "Operation
failed, try again later" -- misleading for a refusal that retrying will
not fix.
Side conversations already got a structured { ok: false, reason } result
for these two codes. Ordinary branches now use the same result, so the
branchFromTurn bridge has one return shape instead of two overloads, and
the turn footer action shows a branch-specific title and a reason for
each code.
Refs apache#5333
Generated-by: Devin
…copy The copy code chose linked-child handling by copy kind: branches rejected (until the previous commit), side conversations snapshotted, revisions shared Graph children and rejected everything else, and a separate gate rejected owned references inside archived tool results for branches and revisions only. Each kind therefore failed on its own subset of sessions; edit-and-resend still always failed once an ordinary subagent had run. The invariant behind all of it is a single one: a copy may share a child only when the copy's own lifecycle keeps that child alive. Agent Graph children retire with their root's revision family, so a revision shares them. Ordinary subagents are independent Sessions that can be removed on their own, and branches and side conversations have their own lifecycle, so every other retained child is copied as a terminal snapshot. - conversation-copy: the reference map is one shape with sharedChildren and inlinedArchives; reject / snapshot / preserve_validated modes and the production-unused preserve_external variant are gone. - prepareLinkedChildCopyReferences returns shared and snapshot children; revision no longer rejects ordinary subagents. - The archived-tool-result gate is gone: every copy inlines archives that hold references it cannot share, as side conversations already did. Behavior change: revisions of sessions with ordinary subagents now succeed with those results as snapshots, and branches/revisions whose archived tool results hold owned references now inline them instead of failing. Refs apache#5333 Generated-by: Devin
Edit-and-resend still rethrew Host refusals, which Electron IPC reduces to a message the renderer can only show as "Operation failed, try again later". Branch and revision now return the same structured copy result from one preload invoker, and both actions show the reason from one copy table (copyFailures) under their own title. Refusals are expected outcomes, so they use an info toast instead of the error toast. Refs apache#5333 Generated-by: Devin
…migration Reverts 6228364 and 2182242. Showing the reason for a refused branch or edit-and-resend needs logic in app-shell-turn-actions.ts and app-shell-revision-actions.ts, which the renderer architecture ledger freezes as root debt (token, import and bridge counts may only shrink). The ledger already assigns both files to features/conversation; doing that move properly needs a conversation session-copy port, new homes for the legacy helpers they import, and render-prop wiring for the revision draft in AppShell. That migration is tracked separately so this change stays the Host fix. Refs apache#5333 Generated-by: Devin
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed exact head e9c7d10a0768d6b3a0a4df14e1f3fdb52f61ce4a (6 files, +403/-585). The change replaces copy-kind-specific linked-child modes with one validated split: revision copies share terminal Agent Graph children; branches, side conversations, and ordinary subagents copy terminal result snapshots. It also lets branch/revision copies inline legacy archived tool results instead of refusing them. I found one P2 capacity boundary in that new inlining path (inline comment). Please bound the aggregate expanded turn or preserve a target-owned archive representation before merging.
I traced the full production diff, admission/terminal and Artifact checks, revision-family retirement, copy rollback, legacy archive rewriting, and the focused/UDS tests. The shared Graph child remains in the revision family that retirement removes together; snapshot child IDs are removed and available Artifacts are copied under the target. The tests cover these ordinary cases, but not multiple large archived results or retirement after a copy. No remaining production use of the removed preserve_external mode was found. No schema or protocol migration is introduced.
Current-head hosted test and label succeed; a fresh-main (592d2d4d) merge-tree and git diff --check are clean. GitHub reports MERGEABLE/BLOCKED. I did not run local suites (Node 18, no dependencies), a live Desktop/Host session, or a large-archive reproduction; the capacity finding follows the cited code path and fixed limits. This is a COMMENTED review, not approval.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| input.sourceSessionId, | ||
| linkedReferences.shared, | ||
| ), | ||
| ) |
There was a problem hiding this comment.
[P2] Bound the aggregate size of legacy archived results before inlining them into one copied Turn. This filter can admit multiple v1 archived linked-child bodies independently: #readArchivedToolResults verifies each body against its own originalBytes (:652-671), while rewriteArchivedSnapshot expands each into a full function_response (conversation-copy.ts:1945-1962) and the copy commits them. A source Turn with two valid ~9 MiB archived subagent summaries is readable as small placeholders, but a branch/revision now embeds >18 MiB of results in its copied Turn. session-transcript-reader.ts:617-621,562-571 rejects any Turn above 16 MiB, so the committed copy cannot be paged/opened. The new UDS test uses only tiny archive bodies. Please enforce a combined presentation bound or keep oversized snapshots in a target-owned archive; I have not run this large-body scenario locally.
There was a problem hiding this comment.
Thanks, the capacity path was real: nothing bounded the combined inlined bodies, and the copy committed before anything read the Turn back. Rather than add a bound, c3c8465 removes the inlining. No shipped build persisted a v1 placeholder inside a RuntimeEvent result (pruning was replay-only before #4350), so the only durable v1 archives are the projection transitions that #4350–#4987 builds wrote, and their events still hold the full result. For those, inlining was also broken on its own: the archive Artifact was excluded from the copy while the transition clone still rewrote its id, so the copy rolled back with missing Artifact (reproduced in the UDS test on e9c7d10). Every archive transition is now rebuilt as a ledger archive of the copied event, the path v2 archives already took, so the copy keeps the result archived, carries no source child ids, and is never larger than the source. The archive preflight, inlinedArchives, archived-result reference discovery and the storage excludeArtifactIds option are gone.
zhiiw
left a comment
There was a problem hiding this comment.
Bound to e9c7d10a0768d6b3a0a4df14e1f3fdb52f61ce4a (re-checked against GitHub immediately before posting, unmoved). Not draft; CI label + test green on this head. Real Windows machine, Node 24.18.1.
No P0–P2, no P3, no inline comments. The one-rule shape ("only revision-Graph children share; everything else snapshots") is pinned from both directions I could ablate on this machine.
Suites on this head (Windows) and attribution
runtime-hostsession-revision-graph-references+session-revision-two-client-uds: 14 pass / 0 fail / 1 skipped — the UDS test is win32-skipped (skip: 'Windows SQLite shutdown lifecycle'). I force-ran it (scratch-patched skip, restored after): the two-client flow executes over named pipe for ~10.5s with no socket error and dies only at teardown onEBUSY: resource busy or locked, unlink runtime.sqlite— exactly the lifecycle the skip names. So on Windows it is runnable but not completable; neither its green nor an ablation red is observable here.runtimeconversation-copy: 16 pass / 13 fail, all 13 the same EBUSY unlink of a tempruntime.sqliteat teardown — the same Windows SQLite file-lock class as above. Zero assertion failures; the copy-logic assertions in those tests are not what fails. Environment, not this PR.
Ablations (each restored, rebuilt, re-verified green)
- Revision-shared Graph children made to snapshot too (
session-revision-graph-references.ts:221, dropped therevision && parent.graphcondition) → exactly the two revision-sharing pins go red:Agent Graph revision references preserve only exact terminal provenanceandonly a revision shares Graph children; every other retained child is a snapshot. The ten Side Conversation / validation siblings stay green, so the pin is scoped to the sharing rule, not the validation machinery around it. - Snapshot reverted to reject (a non-shared retained child now returns
operation_unavailable) → exactlyonly a revision shares Graph children; every other retained child is a snapshotgoes red, plusSide Conversation validates the retained Graph instead of a newer live Graph(same cause — the snapshot path it exercises is now a refusal). The UDS test would catch this too (its previously-rejected branches now assertcommitted), but per above that red is not observable on Windows.
Read, no finding
- The collapse of the reference map (
exact/preserve_external/reject/snapshot/preserve_validated→sharedChildren+inlinedArchives) removes the two mode fields rather than adding a third branch, and the coordinator's copy path got shorter with it — the deleted refusal of owned references inside archived results is exactly the behavior the UDS test now asserts ascommitted, so the relaxation is test-driven, not silent. rewriteConversationCopyMessageno longer gates assistant rewriting onmode === 'exact'— with only one mode left, the condition was dead weight; the rewrite still targetsartifactIdsthe same way.- The two desktop presentation commits net out to zero (the second reverts the first, leaving refusal presentation to the conversation migration).
Not checked
UDS ablation evidence on this OS (skip is justified — see above), e2e, full repo suite.
UTC 2026-09-27 14:04.
Conversation copies expanded every legacy (rewriteVersion 1) archived tool result that held conversation-owned references inline into the copied Turn. Nothing bounded the combined size, and the transcript reader refuses a Turn above 16 MiB, so a copy could commit a Session that cannot be opened. That inlining never served a real archive. No shipped build persisted a v1 placeholder inside a RuntimeEvent result: before apache#4350 pruning was replay only, and from apache#4350 to apache#4987 it wrote a projection transition whose event keeps the full result. For those transitions the inlined archive was also excluded from the Artifact copy while the transition clone still rewrote its Artifact id, so the copy rolled back with "missing Artifact". Every archive transition is now rebuilt as a ledger archive of the copied event, the path v2 archives already take. The copied event holds the snapshotted result, so the archive carries no source child identifiers and needs no Artifact. This removes the archive preflight, inlined archives, archived-result reference discovery and the Artifact copy exclude list. Generated-by: Devin
hqhq1025
left a comment
There was a problem hiding this comment.
Re-review of exact head c3c8465. The previous P2 expansion path is gone: legacy v1 projection transitions now become v2 ledger transitions over the rewritten RuntimeEvent (packages/runtime/src/conversation-copy.ts:1253-1305), rather than inlining archive bodies into copied messages. Also, the original P2 scenario was not established for shipped v1 writers: at #4350 the v1 placeholder was written in a model-projection transition, while the durable RuntimeEvent retained the full result; before that, stale pruning was request-only. I retract the prior claim that multiple shipped v1 placeholders could inflate a copied transcript Turn. The actual old failure was the excluded archive Artifact being required by transition cloning; the new v2 rebuild avoids that missing-map rollback.
The copied snapshot result is rewritten before the new archive digest is calculated (packages/runtime/src/conversation-copy.ts:558-578,1253-1270). Non-shared child identifiers are stripped by rewriteToolResultContent at :1793-1805; graph-shared child identifiers remain intentionally external. The v2 replacement names the target RuntimeEvent and has no source Artifact ID. Source-owned file/context and linked child Artifacts are still selected and mapped; one low-priority over-copy/failure case remains in the inline finding.
Validation: inspected the eight-file follow-up diff, the historical #4350 and #4987 writer paths, runtime copy and rollback flow, and current tests. No local suite or actual Host/Desktop run (Node 18 and no dependencies). Current-head windows_recovery succeeded; test was still in progress at publication. PR base merge-tree and diff-check were clean; current main advanced and a fresh-main local fetch failed, so I did not claim a fresh-main merge check. This is COMMENTED, not merge approval.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| record.sessionId === input.sourceSessionId && | ||
| turnIds.has(record.turnId) && | ||
| !excludedArtifactIds.has(record.id), | ||
| (record) => record.sessionId === input.sourceSessionId && turnIds.has(record.turnId), |
There was a problem hiding this comment.
P3: The new v2 transition no longer needs its old v1 archive Artifact, but the turn-wide selection now copies every tool_result_archive record. For a retained v1 result whose archive metadata survives but payload is missing/corrupt, the raw RuntimeEvent is still available to rebuild v2, yet prepareRecordRead fails and the coordinator rolls back the whole branch/revision. A healthy source also duplicates the obsolete archive bytes in every copy. Repro: retain a v1 transition and raw result, remove only that archive payload, then branch the Turn. Please avoid making an unreferenced legacy archive a required copy input (while preserving any Artifact that historical content actually references), and cover this recovery case.
zhiiw
left a comment
There was a problem hiding this comment.
Bound to c3c846541cdc56407b5101e1f4b3cd7e10f6bcb9 (re-checked against GitHub immediately before posting, unmoved). Not draft; CI test + windows_recovery green on this head. Real Windows machine, Node 24.18.1.
No P0–P2, no P3, no inline comments. The inline→ledger-archive rebuild is pinned, and I got that pin to go red on a Windows-runnable test.
Suites on this head (Windows) and attribution
runtime-hostsession-revision-graph-references: 14/14 green.runtimeconversation-copy: 14/27 — all 13 failures are the teardown class:EBUSY: resource busy or locked, unlink <tmp>\runtime.sqlite(.-shm)in the testfinally. Zero assertion failures at baseline; environment, not this PR (same class as the UDS skip).- The UDS two-client test remains win32-skipped. This round I went one step further than last: force-running it with the teardown made non-fatal (scratch, reverted) shows the flow then dies at a shutdown-lifecycle assertion — the host process exits
SIGTERMwhere the test expects a cleancode 0— before therewriteVersion: 2 / storage: ledgerassertions. So the skip is precisely justified, and the UDS assertion phase is not reachable on this OS.
Ablation (restored, rebuilt, re-verified)
Reverting the rebuild to "keep the legacy placeholder verbatim" (conversation-copy.ts, the buildLedgerArchivedToolResultPlaceholder call in cloneModelProjectionTransition) → the Windows-runnable substitute test conversation copy retains archive reference closure and checkpoints (inspect: false) and (inspect: true) both go red with AssertionError (the rewriteVersion === 2 / bodySha-inequality assertions), with the teardown scratch-tolerated so nothing masks them. On the restored PR code the same two tests fail only with the teardown EBUSY — i.e. their v2 assertions pass. That is the same guarantee the UDS test's new rewriteVersion: 2 / storage: 'ledger' / artifactId: undefined block asserts, proven red-able on this machine.
Note for honesty: my first ablation attempt was invalid — a type-unsafe scratch edit blocked tsc emit, so the test silently ran the previous build. I caught it because the run was indistinguishable from baseline, redid the edit type-safely, and re-verified that the reported red is the assertion, not teardown.
Read, no finding
- The commit's own justification for dropping inlining checks out in code: nothing shipped persists a v1 placeholder inside a RuntimeEvent result, and the inlining path had no size bound against the transcript reader's 16 MiB Turn limit. The rebuild reuses the exact v2 path (
buildLedgerArchivedToolResultPlaceholderover the cloned event, digest re-derived from the clone), and an unmapped predecessor throws instead of silently rooting a successor — the failure direction stays loud. - Deletion completeness: the preflight, the Artifact exclusion list, and the
inlinedArchivesmap are gone from the coordinator and the reference map together;artifact-store.tssheds the exclusion hook. The corresponding test deletions (-235 inconversation-copy.test.ts, -30 inartifact-store.test.ts) are exactly the guards of the removed mechanism.
Not checked
UDS assertion phase on this OS (unreachable — see above), e2e, full repo suite.
UTC 2026-09-27 14:42.
jackwener
left a comment
There was a problem hiding this comment.
Approving at c3c846541cdc56407b5101e1f4b3cd7e10f6bcb9. No open P0–P2.
- One linked-child rule: only a revision shares its Agent Graph children, which retire with the revision family; every other retained terminal child becomes a snapshot (source child id removed, surviving Artifacts copied under the target). Live children are refused. No production caller of the removed
preserve_externalmode remains. - The earlier aggregate-size P2 on inlined v1 archives is withdrawn: no shipped writer persisted a v1 placeholder inside a RuntimeEvent result (#4350–#4987 wrote projection transitions over full events; pre-#4350 pruning was request-only).
c3c846541rebuilds every archive transition as a v2 ledger archive over the copied event, which also closes the old reachablemissing Artifactrollback. - Ablation: keeping the snapshot as reject, or letting revisions snapshot Graph children, reddens exactly the matching graph-references cases; keeping the legacy placeholder instead of rebuilding reddens the
rewriteVersion === 2assertions inconversation-copy.test.ts. On Windows the conversation-copy failures are all teardownEBUSY unlink runtime.sqlite(no assertion failures) and the UDS test is win32-skipped for the same SQLite lifecycle reason. - CI
testandwindows_recoverygreen.
Open P3 (non-blocking, inline 4115738504): the copy still duplicates legacy archive Artifacts that no v2 archive references. If such a payload is missing or corrupt, the whole branch/revision rolls back even though the copied RuntimeEvent is enough to rebuild the archive; when healthy it only costs disk and copy I/O. Worth excluding those Artifacts in a follow-up.
Not run: live Desktop/Host session, large-archive reproduction.
Reviews: #5757 (review) , #5757 (review)
Resolve the import conflict in session-revision-coordinator.ts: main (apache#5757) dropped the unused RuntimeEvent import, and this branch adds the session-name and ExecutionBoundary imports its branch-title code uses. Keep both sides. The branch's net change against main is unchanged. The staged protocol-epoch hook compares the merge with the old branch tip and flags protocol files that arrived from main; the CI-mode check against main is run on the merge commit instead. Generated-by: Claude Code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Branching from a turn that retained a linked child Session always failed in the Host with
Ordinary branches cannot share linked child Session ownership with their source, and Desktop showed only the generic "Operation failed, try again later" toast. The same code path also made edit-and-resend always fail once an ordinary (non-Graph) subagent had run, again with the generic toast.Root cause
Conversation copies (branch, side conversation, revision) chose how to handle linked children based on the kind of copy. Branches rejected. Side conversations made snapshots. Revisions shared Agent Graph children and rejected everything else. On top of that, a separate gate rejected owned references inside archived tool results, but only for branches and revisions, while side conversations wrote those archives inline. So each kind of copy failed on its own subset of sessions.
All of these cases come down to one invariant: a copy may share a child only when the copy's own lifecycle keeps that child alive. Agent Graph children retire together with their root's revision family (
session-retirement-coordinator), so a revision can share them. An ordinary subagent is an independent Session that can be removed on its own. Branches and side conversations have their own lifecycle. So every other retained child has to be copied as a snapshot of its terminal result.Change
@maka/runtimeconversation-copy: the reference map now has one shape,sharedChildren. Removed the threereject/snapshot/preserve_validatedmodes, thepreserve_externalvariant (no production code used it), themodediscriminant, andarchivedToolResultContainsLinkedChildReferences.missing Artifactbecause the inlined archive was excluded from the Artifact copy. No shipped build persisted a v1 placeholder inside a RuntimeEvent result (pruning was replay-only before refactor(runtime): unify durable model-context projection authority #4350), so the inlining served no real data. Removed with it: the archive preflight, archived-result reference discovery,archivedToolResultContainsConversationOwnedReferences, and the storageexcludeArtifactIdscopy option.prepareLinkedChildCopyReferencesvalidates every retained child the same way and returnsshared(only a revision's Graph children) andsnapshots(all other children). The coordinator applies these the same way for every kind of copy, and the archived-tool-result gate is gone. Copy kinds now differ only in slice boundary, source-active admission, labels/intent, revision-family metadata and Todo inheritance.session_busy, oroperation_unavailablefor unsupported content), Desktop shows the generic toast as before. Showing the reason needs logic inapp-shell-turn-actions.ts/app-shell-revision-actions.ts, but the renderer architecture ledger freezes those files as root debt and assigns them tofeatures/conversation. That migration is tracked in Desktop shows a generic failure when the Host refuses a Branch or edit-and-resend #5762.Behavior change
Fixes #5333
Refs #5762
Verification
session-revision-two-client-uds.test.ts(real Host over UDS; fails onmain, passes here):main, the revision fails withSession revision requires a terminal result for every retained Agent Graph child. Here it commits.Conversation copy is missing Artifact archived-owned-result. Here it commits.childSessionId/runId, Artifact readable inside the copy). The revision still shares its Graph child. In both the branch and the side conversation, the archived result's copied transition is a rewriteVersion 2 ledger archive of the copied event, with no Artifact id and a body digest that matches the copied snapshot.session-revision-graph-references.test.ts: a new test covers that only a revision shares Graph children and that every other kind / child pair is a snapshot.conversation-copy.test.tswas migrated to the single map shape and covers snapshots, validation of shared-child ids, and legacy archive transitions rebuilt over the copied event (chained and rival roots).@maka/runtime-host: 2143 pass. 2 failures: sandbox Bash, which also fails on the rootmainbuild, and the real-model implementation child patch test, which timed out under suite concurrency and passes when run alone.@maka/runtime: 3619 pass. 7 failures, all infilesystem-worker-smoke, which also fails on the rootmainbuild. Both sandbox failures are environmental (macOS sandbox inside the agent shell).@maka/storage: 1457 pass.@maka/storage/@maka/runtime/@maka/runtime-hosttypecheck,npm run format,npm run lint,knipfor storage/runtime/runtime-host: no new findings.check-renderer-architecturepasses against the base.AI use
Select exactly one:
Tool(s) and scope: Devin wrote the diagnosis, implementation, tests and verification.
Checklist
Does this PR entail a change in behavior?