Repository navigation
[Story 2.2] Serialize reopenParentFromDelegation steps 4–5 #365
Description
Part of #357 (Epic 2: Task Lifecycle Fixes).
Depends on: Nothing — can start immediately. Uses atomicReadAndUpdate from Story 2.1, but the implementation is independent.
Context
The current reopenParentFromDelegation writes parent and child status in separate, non-atomic steps:
- Step 3 — write parent
status: "active"(withawaitingChildIdstill set as a crash-recovery sentinel) - Step 4 — close child instance via
removeClineFromStack - Step 5 — write child
status: "completed" - Step 5B — write parent
awaitingChildId: undefinedto clear the sentinel
This multi-step saga with a sentinel is the wrong shape. It:
- Introduces an intermediate persisted state (
active+awaitingChildIdset) that is not a valid steady state in the status machine - Couples
reconcileDelegationStateto the internal protocol ofreopenParentFromDelegation(the reconciler must understand what the sentinel means) - Creates a window where parent is
activebut child is stillactive— visible to any concurrent reader between steps 3 and 5
The tech brief (#355) explicitly rejected sentinel/intent-state approaches in favour of the atomicReadAndUpdate pattern.
Correct Implementation
1. Add atomicUpdatePair to TaskHistoryStore
/**
* Atomically update two related HistoryItems within a single lock acquisition.
* Both updaters run synchronously (no I/O, no lock re-entry). Both writes are
* committed before the lock releases — no concurrent writer can observe an
* intermediate state.
*/
public atomicUpdatePair(
firstId: string,
secondId: string,
firstUpdater: (current: HistoryItem) => HistoryItem,
secondUpdater: (current: HistoryItem) => HistoryItem,
): Promise<HistoryItem[]> {
return this.withLock(async () => {
const first = this.cache.get(firstId)
if (!first) throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${firstId} not found`)
const second = this.cache.get(secondId)
if (!second) throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${secondId} not found`)
const updatedFirst = firstUpdater(structuredClone(first))
const updatedSecond = secondUpdater(structuredClone(second))
await this.upsertCore(updatedFirst)
await this.upsertCore(updatedSecond)
return this.getAll()
})
}2. Rewrite reopenParentFromDelegation steps 3–5B
Replace the current sentinel approach (steps 3, 5, 5B) with a single atomicUpdatePair call after removeClineFromStack (step 4):
// 4) Close child instance (must happen before marking child completed —
// removeClineFromStack → abortTask → saveClineMessages writes status: "active"
// which would overwrite a "completed" status set earlier)
const current = this.getCurrentTask()
if (current?.taskId === childTaskId) {
await this.removeClineFromStack({ skipDelegationRepair: true })
}
// 3+5) Atomically mark child completed and parent active in one lock acquisition.
// No intermediate state is ever persisted — no sentinel needed.
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId]))
await this.taskHistoryStore.atomicUpdatePair(
childTaskId,
parentTaskId,
(child) => {
assertValidTransition(child.status, "completed")
return { ...child, status: "completed" }
},
(parent) => {
if (parent.status !== "active") assertValidTransition(parent.status, "active")
return {
...parent,
status: "active",
completedByChildId: childTaskId,
completionResultSummary,
awaitingChildId: undefined,
childIds,
}
},
)3. Remove the sentinel recovery case from reconcileDelegationState
The active + awaitingChildId recovery case added in PR #692 exists solely to handle the sentinel. Once atomicUpdatePair is in place, no valid handoff ever leaves a parent active with awaitingChildId set — that intermediate state is no longer persisted. Remove that case.
Order matters: child before parent
The child must be written first (or atomically with the parent). Writing parent first (as the current sentinel approach does) means a crash window where parent is active with no awaitingChildId — the parent looks complete but the child is still active with no way to report back. Writing child first (or both atomically) means a crash leaves child active and parent delegated — which reconcileDelegationState already handles correctly (delegated parent + active child = leave as-is, resumable).
Files
src/core/task-persistence/TaskHistoryStore.ts— addatomicUpdatePairsrc/core/webview/ClineProvider.ts— replace steps 3/5/5B inreopenParentFromDelegationsrc/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts— remove theactive + awaitingChildIdsentinel recovery tests added in PR feat(task-lifecycle): task status transition guard and startup delegation reconciliation #692; addatomicUpdatePairunit testssrc/__tests__/history-resume-delegation.spec.ts— update handoff tests; add partial-failure test
Tests
atomicUpdatePairunit tests: assert both records are updated atomically; assert a concurrentupsertqueued during the pair write sees the completed state of both records after the lock releases.- Partial failure: mock
upsertCoreto throw on the second write; assert neither record is in an inconsistent final state (first write already landed — document this known limitation, or add a compensating write in the catch). - Handoff correctness: after
reopenParentFromDelegation, assertchild.status === "completed"ANDparent.status === "active"ANDparent.awaitingChildId === undefined— never one without the others. - All 11 existing tests in
history-resume-delegation.spec.tscontinue to pass.
Acceptance Criteria
- No observable window where
child.status !== "completed"whileparent.status === "active"(or vice versa) afterreopenParentFromDelegationreturns. reconcileDelegationStatehas no knowledge ofreopenParentFromDelegationinternals — the sentinel recovery case is gone.- Partial failure within
atomicUpdatePair(secondupsertCorethrows after first succeeds) surfaces the error; behaviour is documented.
Activity
edelauna commentedon Jun 17, 2026
E2E evidence of the
resumeAfterDelegationrace (PR #250 merge queue)The merge queue run for PR #250 (job log) hit this race in the
same-profile child returns before a different-profile childE2E test.What happened
code, modelopenai/gpt-4.1) delegates to a same-profile child vianew_task"Same-profile child completed"reopenParentFromDelegationfires → parent callsresumeAfterDelegation→ API request sentSUBTASK_XPROFILE_SAME_CHILD_RESULT), so the aimock fixture atapps/vscode-e2e/src/fixtures/subtasks.ts:199(which predicates on bothXPROFILE_PARENT_MARKERandSAME_CHILD_RESULTbeing present) doesn't match →404 No fixture matchedcompletionAfterAnswerat line 142) matches loosely, returningattempt_completionwithnew_task-shaped arguments → parse failure → task abortsStack trace from logs
The two
404 No fixture matchederrors at12:33:40and12:33:42confirm the parent's conversation history was missing the child's result when the API call fired — exactly the race where steps 4–5 ofreopenParentFromDelegation(child →completed, parent →active+ history injection) aren't atomic.Reproduction
Non-deterministic — only triggered once across ~20 recent E2E runs. The cross-profile subtask test is the most sensitive because it chains two sequential delegations, doubling the race window.