diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 9266d49987..d7b6a1b361 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -46,7 +46,7 @@ TLA+/PlusCal or Quint with TLC becomes a better fit when the lifecycle needs tem | Atomic event step | `atomicReadAndUpdate`, `atomicUpdatePair`, and per-parent delegation transition lock | | Event interleaving | Competing completion, cancellation, abandonment, and new delegation calls | -The model has three fixed task slots, enough to cover competing siblings and a nested parent-child-grandchild chain. It explores every reachable interleaving through depth 12, deduplicating canonical states. Representative checks also exercise rejected operations that do not create a new state: a second concurrent delegation while the first child is active, stale completion after re-delegation, late completion after abandonment, completion after interruption, and nested completion. Named semantic landmarks require the graph to retain interrupted-child re-delegation and nested delegation even when the raw state total changes. +The model has three fixed task slots, enough to cover competing siblings and a nested parent-child-grandchild chain. It explores every reachable interleaving through depth 12, deduplicating canonical states. Runtime ownership is modeled separately from persisted status: an `owner-loss` fault can remove the live owner of an active or delegated child without changing its history record, matching process termination or session skip. Recovery is enabled only when the awaited delegation chain has no live owner and ends in an interrupted/completed task or a missing awaited record. Representative checks also exercise rejected operations that do not create a new state: a second concurrent delegation while the first child is active, stale completion after re-delegation, late completion after abandonment, completion after interruption, and nested completion. Named semantic landmarks require the graph to retain interrupted-child re-delegation, nested delegation, delegated owner loss, and dead-chain recovery even when the raw state total changes. Production completion also accepts a recovery-compatible `active` parent that still awaits the returning child, then clears the stale pointers. Normal model transitions never create that intermediate state, so it is covered by a focused reducer test rather than admitted as a generally valid reachable state. @@ -114,7 +114,8 @@ The task delegation checker currently enforces: 4. Every active or delegated linked child is the child its parent currently awaits. An interrupted prior child may retain lineage after re-delegation but cannot complete back into that parent. 5. Parent-child lineage is acyclic. 6. Completed task records cannot be changed by later lifecycle events. -7. Active-child re-delegation, stale completion after ownership moves to another child, duplicate/late completion, and abandonment of a live child are rejected by the shared production guards. +7. Active-child re-delegation, stale completion after ownership moves to another child, duplicate/late completion, and abandonment of a live child are rejected by the shared production guards. A delegated child may transition to `interrupted` only through dead-chain recovery after runtime liveness checks establish that neither it nor its descendants has a live owner. +8. Runtime owner loss does not mutate persisted status. A delegated chain with no remaining live owner has a reachable recovery transition within the bounded graph, after which its parent can re-delegate. The completion persistence checker additionally enforces: @@ -130,16 +131,17 @@ These are safety claims within the documented bounds. The checks do not claim li The following map separates issue observations from the architectural interpretation encoded here. Open issues can change after this document is written; follow each link for current status. -| Issue and directly observed evidence | Derived protocol rule | Production transition and current check | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): the issue report states that a barrier-controlled two-host run reproduced an old child completion clearing a newer handoff 25/25 times. | Completion is conditional on the parent still awaiting that exact child; a live-linked child must remain owned by its parent. | `completeDelegatedChild` rejects stale authoritative input. The lifecycle explorer checks that reducer rule, while the shared-store explorer reproduces the cross-host stale-cache counterexample with an exact causal witness. | -| [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021): an in-flight `saveClineMessages` can restore parent/root IDs after abandonment cleared them. | Detachment should be monotonic: later lifecycle work must not reattach an abandoned child. | `abandonDelegatedChild` clears both sides. The shared-store explorer proves the detach commit occurs, then reproduces a refreshed-cache delta that preserves interrupted status while restoring stale live-task lineage. | -| [#1453](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1453), under user report [#1279](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1279): CI observed `TaskCompleted` before restart-visible API history once; 120 local repetitions did not reproduce it. | Completion implies restart-visible assistant history. Delayed or failed writes keep completion pending, and cancellation settles readiness without starting stale retries or emitting completion. | The completion persistence explorer checks the bounded event-ordering and cancellation contract for standalone and delegated tasks. Focused `Task` and `AttemptCompletionTool` tests cover the production adapter; `restart-persistence.test.ts` verifies visibility through a fresh extension host. | -| [#921](https://github.com/Zoo-Code-Org/Zoo-Code/issues/921): delegation across parallel tabs lacks coverage for different view-local mode/profile state. | Delegation must bind an explicit immutable execution-context snapshot rather than read whichever view is focused later. | The persisted ownership transition is covered; mode/profile snapshot isolation is outside this state model and belongs in a production adapter/model-based test. | -| [#920](https://github.com/Zoo-Code-Org/Zoo-Code/issues/920): issue analysis identifies a missing cross-instance history-update test and potential lost writes. | Distinct task writes must not overwrite one another, and same-task conflicts need an explicit merge/ownership rule. | The shared-store explorer checks distinct-task writes and same-record independent deltas. Cross-instance store tests retain production API coverage, and the synchronized real-filesystem smoke test exercises the actual lock/write path without claiming exhaustive filesystem proof. | -| [#369](https://github.com/Zoo-Code-Org/Zoo-Code/issues/369) and [#372](https://github.com/Zoo-Code-Org/Zoo-Code/issues/372): planned fan-out keeps a parent live while a child runs and requires completion routing by explicit parent ID, single-writer result readiness, permit release, and orphan cleanup. | Persisted `delegated` status is ownership, not proof that the parent instance is suspended. Completion must route by IDs; scheduler resources and live-instance state need separate invariants. | Nested and sibling lifecycle ownership are covered. Scheduler permits, live/suspended parent selection, orphan cancellation, and single-writer message readiness must be added when fan-out lands; they should not be folded into `HistoryItem` fields prematurely. | -| [#1468](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1468): a late chunk from one request combined tool identity with arguments from another request; rerun passed. | Every stream accumulator needs a request/task generation key, and late events cannot mutate another scope. | Separate protocol. The [native tool-call parser request-scope model](./native-tool-call-parser-scoping-model.md), whose source of truth is `scripts/check-native-tool-call-parser-scoping.ts`, exhaustively replays bounded production-parser interleavings without adding fields to this lifecycle model. | -| [#612](https://github.com/Zoo-Code-Org/Zoo-Code/issues/612): the CLI copied a status union and omitted `interrupted`. | Lifecycle vocabulary should have one type owner. | `HistoryItemStatus` is derived from `HistoryItem`, and production/checker transitions share `taskLifecycle.ts`; consumers should import rather than copy the union. | +| Issue and directly observed evidence | Derived protocol rule | Production transition and current check | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#1624](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1624): a nested chain persisted as parent → delegated child → interrupted grandchild permanently blocked parent re-delegation after the intermediate child lost its live session. | A delegated status records ownership, not liveness. A delegated intermediate child may be recovered only when its descendant chain terminates dead and no node in that chain has a live runtime owner. | `recoverDeadDelegatedChild` performs the shared transition. Startup reconciliation repairs persisted dead chains, runtime re-delegation checks every active provider registry before recovery, and the lifecycle explorer requires the dead-nested-chain landmark to remain reachable. | +| [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): the issue report states that a barrier-controlled two-host run reproduced an old child completion clearing a newer handoff 25/25 times. | Completion is conditional on the parent still awaiting that exact child; a live-linked child must remain owned by its parent. | `completeDelegatedChild` rejects stale authoritative input. The lifecycle explorer checks that reducer rule, while the shared-store explorer reproduces the cross-host stale-cache counterexample with an exact causal witness. | +| [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021): an in-flight `saveClineMessages` can restore parent/root IDs after abandonment cleared them. | Detachment should be monotonic: later lifecycle work must not reattach an abandoned child. | `abandonDelegatedChild` clears both sides. The shared-store explorer proves the detach commit occurs, then reproduces a refreshed-cache delta that preserves interrupted status while restoring stale live-task lineage. | +| [#1453](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1453), under user report [#1279](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1279): CI observed `TaskCompleted` before restart-visible API history once; 120 local repetitions did not reproduce it. | Completion implies restart-visible assistant history. Delayed or failed writes keep completion pending, and cancellation settles readiness without starting stale retries or emitting completion. | The completion persistence explorer checks the bounded event-ordering and cancellation contract for standalone and delegated tasks. Focused `Task` and `AttemptCompletionTool` tests cover the production adapter; `restart-persistence.test.ts` verifies visibility through a fresh extension host. | +| [#921](https://github.com/Zoo-Code-Org/Zoo-Code/issues/921): delegation across parallel tabs lacks coverage for different view-local mode/profile state. | Delegation must bind an explicit immutable execution-context snapshot rather than read whichever view is focused later. | The persisted ownership transition is covered; mode/profile snapshot isolation is outside this state model and belongs in a production adapter/model-based test. | +| [#920](https://github.com/Zoo-Code-Org/Zoo-Code/issues/920): issue analysis identifies a missing cross-instance history-update test and potential lost writes. | Distinct task writes must not overwrite one another, and same-task conflicts need an explicit merge/ownership rule. | The shared-store explorer checks distinct-task writes and same-record independent deltas. Cross-instance store tests retain production API coverage, and the synchronized real-filesystem smoke test exercises the actual lock/write path without claiming exhaustive filesystem proof. | +| [#369](https://github.com/Zoo-Code-Org/Zoo-Code/issues/369) and [#372](https://github.com/Zoo-Code-Org/Zoo-Code/issues/372): planned fan-out keeps a parent live while a child runs and requires completion routing by explicit parent ID, single-writer result readiness, permit release, and orphan cleanup. | Persisted `delegated` status is ownership, not proof that the parent instance is suspended. Completion must route by IDs; scheduler resources and live-instance state need separate invariants. | Nested and sibling lifecycle ownership are covered. Scheduler permits, live/suspended parent selection, orphan cancellation, and single-writer message readiness must be added when fan-out lands; they should not be folded into `HistoryItem` fields prematurely. | +| [#1468](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1468): a late chunk from one request combined tool identity with arguments from another request; rerun passed. | Every stream accumulator needs a request/task generation key, and late events cannot mutate another scope. | Separate protocol. The [native tool-call parser request-scope model](./native-tool-call-parser-scoping-model.md), whose source of truth is `scripts/check-native-tool-call-parser-scoping.ts`, exhaustively replays bounded production-parser interleavings without adding fields to this lifecycle model. | +| [#612](https://github.com/Zoo-Code-Org/Zoo-Code/issues/612): the CLI copied a status union and omitted `interrupted`. | Lifecycle vocabulary should have one type owner. | `HistoryItemStatus` is derived from `HistoryItem`, and production/checker transitions share `taskLifecycle.ts`; consumers should import rather than copy the union. | The issue-derived cases intentionally map to bug classes rather than issue-specific flags. In particular, stale event ownership, monotonic terminal/detached state, explicit scope, and single-writer boundaries generalize to future concurrent task work. diff --git a/scripts/check-task-lifecycle.ts b/scripts/check-task-lifecycle.ts index 73e9078366..2e2c11e6f2 100644 --- a/scripts/check-task-lifecycle.ts +++ b/scripts/check-task-lifecycle.ts @@ -7,11 +7,13 @@ import { completeDelegatedChild, delegateTaskToChild, interruptDelegatedChild, + isDeadDelegationChain, + recoverDeadDelegatedChild, } from "../src/core/task-persistence/taskLifecycle" const taskIds = ["parent", "child-a", "child-b"] as const type TaskId = (typeof taskIds)[number] -type ModelState = Record +type ModelState = Record & { liveTaskIds: TaskId[] } interface Transition { name: string @@ -25,7 +27,7 @@ interface TraceStep { const MAX_DEPTH = 12 const MAX_STATES = 10_000 -const expectedActions = ["delegate", "interrupt", "complete", "abandon"] as const +const expectedActions = ["delegate", "owner-loss", "interrupt", "recover", "complete", "abandon"] as const const semanticLandmarks = { "interrupted-child-redelegation": (state: ModelState) => state.parent?.status === "delegated" && @@ -36,6 +38,14 @@ const semanticLandmarks = { state.parent.awaitingChildId === "child-a" && state["child-a"]?.status === "delegated" && state["child-a"].awaitingChildId === "child-b", + "delegated-owner-loss": (state: ModelState) => + state["child-a"]?.status === "delegated" && !state.liveTaskIds.includes("child-a"), + "dead-nested-chain-recovered": (state: ModelState) => + state.parent?.status === "delegated" && + state.parent.awaitingChildId === "child-a" && + state["child-a"]?.status === "interrupted" && + state["child-a"].awaitingChildId === undefined && + state["child-b"]?.status === "interrupted", } satisfies Record boolean> function task(id: TaskId, parentTaskId?: TaskId): HistoryItem { @@ -55,7 +65,7 @@ function task(id: TaskId, parentTaskId?: TaskId): HistoryItem { } function initialState(): ModelState { - return { parent: task("parent"), "child-a": undefined, "child-b": undefined } + return { parent: task("parent"), "child-a": undefined, "child-b": undefined, liveTaskIds: ["parent"] } } function replace(state: ModelState, ...updates: HistoryItem[]): ModelState { @@ -64,6 +74,10 @@ function replace(state: ModelState, ...updates: HistoryItem[]): ModelState { return next } +function withLiveTasks(state: ModelState, ...liveTaskIds: TaskId[]): ModelState { + return { ...state, liveTaskIds: Array.from(new Set(liveTaskIds)).sort() } +} + function transitions(state: ModelState): Transition[] { const result: Transition[] = [] for (const parentId of taskIds) { @@ -77,9 +91,20 @@ function transitions(state: ModelState): Transition[] { continue } const delegated = delegateTaskToChild(parent, childId, awaitedStatus) + const next = replace(state, delegated, task(childId, parentId)) result.push({ name: `delegate(${parentId}, ${childId})`, - next: replace(state, delegated, task(childId, parentId)), + next: withLiveTasks(next, ...state.liveTaskIds, childId), + }) + } + } + + for (const taskId of state.liveTaskIds) { + const current = state[taskId] + if (current && current.parentTaskId && (current.status === "active" || current.status === "delegated")) { + result.push({ + name: `owner-loss(${taskId})`, + next: withLiveTasks(state, ...state.liveTaskIds.filter((id) => id !== taskId)), }) } } @@ -92,7 +117,23 @@ function transitions(state: ModelState): Transition[] { if (parent.status === "delegated" && parent.awaitingChildId === child.id && child.status === "active") { const interrupted = interruptDelegatedChild(parent, child) - result.push({ name: `interrupt(${childId})`, next: replace(state, interrupted) }) + result.push({ + name: `interrupt(${childId})`, + next: withLiveTasks(replace(state, interrupted), ...state.liveTaskIds.filter((id) => id !== childId)), + }) + } + + if ( + parent.status === "delegated" && + parent.awaitingChildId === child.id && + isDeadDelegationChain( + child, + (id) => state[id as TaskId], + (id) => state.liveTaskIds.includes(id as TaskId), + ) + ) { + const recovered = recoverDeadDelegatedChild(parent, child) + result.push({ name: `recover(${childId})`, next: replace(state, recovered) }) } if ( @@ -103,7 +144,11 @@ function transitions(state: ModelState): Transition[] { const completed = completeDelegatedChild(parent, child, `${childId} result`) result.push({ name: `complete(${childId})`, - next: replace(state, completed.parent, completed.child), + next: withLiveTasks( + replace(state, completed.parent, completed.child), + ...state.liveTaskIds.filter((id) => id !== childId), + child.parentTaskId as TaskId, + ), }) } @@ -111,12 +156,33 @@ function transitions(state: ModelState): Transition[] { const abandoned = abandonDelegatedChild(parent, child) result.push({ name: `abandon(${childId})`, - next: replace(state, abandoned.parent, abandoned.child), + next: withLiveTasks( + replace(state, abandoned.parent, abandoned.child), + ...state.liveTaskIds.filter((id) => id !== childId), + child.parentTaskId as TaskId, + ), }) } } + return result } +function deadDelegatedChildren(state: ModelState): TaskId[] { + return taskIds.filter((childId) => { + const child = state[childId] + if (!child?.parentTaskId) return false + const parent = state[child.parentTaskId as TaskId] + return ( + parent?.status === "delegated" && + parent.awaitingChildId === child.id && + isDeadDelegationChain( + child, + (id) => state[id as TaskId], + (id) => state.liveTaskIds.includes(id as TaskId), + ) + ) + }) +} function invariantViolations(state: ModelState): string[] { const violations: string[] = [] @@ -158,11 +224,16 @@ function invariantViolations(state: ModelState): string[] { cursor = state[cursor as TaskId]?.parentTaskId } } + for (const childId of deadDelegatedChildren(state)) { + if (!transitions(state).some((transition) => transition.name === `recover(${childId})`)) { + violations.push(`${childId}: dead delegated chain must be recoverable in the next transition`) + } + } return violations } function canonical(state: ModelState): string { - return JSON.stringify(taskIds.map((id) => state[id] ?? null)) + return JSON.stringify({ tasks: taskIds.map((id) => state[id] ?? null), liveTaskIds: state.liveTaskIds }) } function formatCounterexample(message: string, trace: TraceStep[]): string { @@ -274,6 +345,13 @@ function runRepresentativeScenarios(): void { const nestedCompletion = completeDelegatedChild(nestedParent, childB, "nested result") assert.equal(nestedCompletion.parent.status, "active") assert.equal(nestedCompletion.parent.completedByChildId, childB.id) + const interruptedNestedChild = interruptDelegatedChild(nestedParent, childB) + const recoveredNestedParent = recoverDeadDelegatedChild(delegated, { + ...nestedParent, + awaitingChildId: interruptedNestedChild.id, + }) + assert.equal(recoveredNestedParent.status, "interrupted") + assert.equal(recoveredNestedParent.awaitingChildId, undefined) const interruptedCompletion = completeDelegatedChild(delegated, interruptedA, "resumed result") assert.equal(interruptedCompletion.child.status, "completed") diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 422c264e2c..9eb173efd0 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -519,11 +519,12 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { return [] }), }) + const parentTask = makeParentTask() const provider = { taskScheduler: new TaskScheduler(), emit: vi.fn(), - getCurrentTask: vi.fn(() => makeParentTask()), + getCurrentTask: vi.fn(() => parentTask), removeClineFromStack: vi.fn().mockResolvedValue(undefined), createTask: vi.fn().mockResolvedValue({ taskId: "child-2", start: vi.fn(), run: () => Promise.resolve() }), handleModeSwitch: vi.fn().mockResolvedValue(undefined), @@ -553,6 +554,432 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(result.childIds).toContain("child-2") }) + it("recovers a dead nested chain and re-delegates the parent", async () => { + const oldChildId = "old-child" + const grandchildId = "grandchild" + const records = new Map([ + [ + "parent-1", + { + ...parentHistoryItem, + status: "delegated", + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + childIds: [oldChildId], + }, + ], + [ + oldChildId, + { + ...parentHistoryItem, + id: oldChildId, + status: "delegated", + parentTaskId: "parent-1", + awaitingChildId: grandchildId, + delegatedToId: grandchildId, + }, + ], + [ + grandchildId, + { + ...parentHistoryItem, + id: grandchildId, + status: "interrupted", + parentTaskId: oldChildId, + }, + ], + ]) + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => records.get(id)), + atomicReadAndUpdate: vi.fn(async (taskId: string, updater: (item: HistoryItem) => HistoryItem) => { + const updated = updater(records.get(taskId)!) + records.set(taskId, updated) + return [] + }), + }) + const parentTask = makeParentTask() + const provider = { + taskScheduler: new TaskScheduler(), + taskRegistry: { hasRunning: vi.fn().mockReturnValue(false) }, + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue({ taskId: "child-2", run: vi.fn().mockResolvedValue(undefined) }), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + Object.assign(provider, { + isTaskRunningInAnyProvider: ClineProvider.prototype["isTaskRunningInAnyProvider"], + refreshDelegationChain: ClineProvider.prototype["refreshDelegationChain"], + recoverDeadAwaitedChild: ClineProvider.prototype["recoverDeadAwaitedChild"], + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }) + + expect(records.get(oldChildId)).toMatchObject({ + status: "interrupted", + awaitingChildId: undefined, + delegatedToId: undefined, + }) + expect(records.get("parent-1")).toMatchObject({ status: "delegated", awaitingChildId: "child-2" }) + }) + + it("does not recover a nested chain with a live task owner", async () => { + const oldChildId = "old-child" + const grandchildId = "grandchild" + const parent = { + ...parentHistoryItem, + status: "delegated" as const, + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } + const child = { + ...parentHistoryItem, + id: oldChildId, + status: "delegated" as const, + awaitingChildId: grandchildId, + delegatedToId: grandchildId, + } + const grandchild = { ...parentHistoryItem, id: grandchildId, status: "interrupted" as const } + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => (id === "parent-1" ? parent : id === oldChildId ? child : grandchild)), + }) + const provider = { + taskRegistry: { hasRunning: vi.fn((id: string) => id === grandchildId) }, + getCurrentTask: vi.fn(() => makeParentTask()), + removeClineFromStack: vi.fn(), + createTask: vi.fn(), + taskHistoryStore, + } as unknown as ClineProvider + Object.assign(provider, { + isTaskRunningInAnyProvider: ClineProvider.prototype["isTaskRunningInAnyProvider"], + refreshDelegationChain: ClineProvider.prototype["refreshDelegationChain"], + recoverDeadAwaitedChild: ClineProvider.prototype["recoverDeadAwaitedChild"], + }) + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(`awaited child ${oldChildId} has status delegated`) + expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled() + expect(provider.createTask).not.toHaveBeenCalled() + }) + + it("detects a live delegation owner in another provider instance", () => { + const provider = { + taskRegistry: { hasRunning: vi.fn().mockReturnValue(false) }, + } as unknown as ClineProvider + const otherProvider = { + taskRegistry: { hasRunning: vi.fn((taskId: string) => taskId === "live-child") }, + } as unknown as ClineProvider + const activeInstances = Reflect.get(ClineProvider, "activeInstances") + if (!(activeInstances instanceof Set)) throw new Error("ClineProvider active instance registry unavailable") + activeInstances.add(otherProvider) + + try { + expect(ClineProvider.prototype["isTaskRunningInAnyProvider"].call(provider, "live-child")).toBe(true) + } finally { + activeInstances.delete(otherProvider) + } + }) + + it("rejects when a dead delegation chain becomes live during its atomic recovery", async () => { + const oldChildId = "old-child" + const grandchildId = "grandchild" + const parent = { + ...parentHistoryItem, + status: "delegated" as const, + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } + const child = { + ...parentHistoryItem, + id: oldChildId, + status: "delegated" as const, + awaitingChildId: grandchildId, + delegatedToId: grandchildId, + } + const grandchild = { ...parentHistoryItem, id: grandchildId, status: "interrupted" as const } + let childIsLive = false + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => (id === "parent-1" ? parent : id === oldChildId ? child : grandchild)), + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { + childIsLive = true + updater(child) + return [] + }), + }) + const provider = { + taskRegistry: { hasRunning: vi.fn((id: string) => id === oldChildId && childIsLive) }, + getCurrentTask: vi.fn(() => makeParentTask()), + removeClineFromStack: vi.fn(), + createTask: vi.fn(), + taskHistoryStore, + } as unknown as ClineProvider + Object.assign(provider, { + isTaskRunningInAnyProvider: ClineProvider.prototype["isTaskRunningInAnyProvider"], + refreshDelegationChain: ClineProvider.prototype["refreshDelegationChain"], + recoverDeadAwaitedChild: ClineProvider.prototype["recoverDeadAwaitedChild"], + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(`Delegation chain for child ${oldChildId} became live during recovery`) + expect(provider.createTask).not.toHaveBeenCalled() + }) + + it("does not recover when a descendant is live in another provider", async () => { + const oldChildId = "old-child" + const grandchildId = "grandchild" + const parent = { + ...parentHistoryItem, + status: "delegated" as const, + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } + const child = { + ...parentHistoryItem, + id: oldChildId, + status: "delegated" as const, + awaitingChildId: grandchildId, + delegatedToId: grandchildId, + } + const grandchild = { ...parentHistoryItem, id: grandchildId, status: "active" as const } + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => (id === oldChildId ? child : id === grandchildId ? grandchild : parent)), + }) + const provider = { + taskRegistry: { hasRunning: vi.fn().mockReturnValue(false) }, + taskHistoryStore, + } as unknown as ClineProvider + const otherProvider = { + taskRegistry: { hasRunning: vi.fn((id: string) => id === grandchildId) }, + } as unknown as ClineProvider + const activeInstances = Reflect.get(ClineProvider, "activeInstances") + if (!(activeInstances instanceof Set)) throw new Error("ClineProvider active instance registry unavailable") + activeInstances.add(otherProvider) + Object.assign(provider, { + isTaskRunningInAnyProvider: ClineProvider.prototype["isTaskRunningInAnyProvider"], + refreshDelegationChain: ClineProvider.prototype["refreshDelegationChain"], + }) + + try { + const result = await ClineProvider.prototype["recoverDeadAwaitedChild"].call(provider, parent, oldChildId) + expect(result).toBe(child) + expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled() + } finally { + activeInstances.delete(otherProvider) + } + }) + + it("uses refreshed delegation records when deciding recovery", async () => { + const oldChildId = "old-child" + const grandchildId = "grandchild" + const parent = { + ...parentHistoryItem, + status: "delegated" as const, + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } + const refreshedChild = { + ...parentHistoryItem, + id: oldChildId, + status: "delegated" as const, + awaitingChildId: grandchildId, + delegatedToId: grandchildId, + } + const refreshedGrandchild = { ...parentHistoryItem, id: grandchildId, status: "interrupted" as const } + const cache = new Map([[oldChildId, { ...refreshedChild, status: "active" }]]) + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => cache.get(id)), + atomicReadAndUpdate: vi.fn(async (id: string, updater: (item: HistoryItem) => HistoryItem) => { + cache.set(id, updater(cache.get(id)!)) + return [] + }), + }) + taskHistoryStore.invalidate.mockImplementation(async (id: string) => { + if (id === oldChildId) cache.set(id, refreshedChild) + if (id === grandchildId) cache.set(id, refreshedGrandchild) + }) + const provider = { + taskRegistry: { hasRunning: vi.fn().mockReturnValue(false) }, + taskHistoryStore, + log: vi.fn(), + } as unknown as ClineProvider + Object.assign(provider, { + isTaskRunningInAnyProvider: ClineProvider.prototype["isTaskRunningInAnyProvider"], + refreshDelegationChain: ClineProvider.prototype["refreshDelegationChain"], + }) + + await ClineProvider.prototype["recoverDeadAwaitedChild"].call(provider, parent, oldChildId) + + expect(taskHistoryStore.invalidate).toHaveBeenCalledWith(oldChildId) + expect(taskHistoryStore.invalidate).toHaveBeenCalledWith(grandchildId) + expect(cache.get(oldChildId)).toMatchObject({ status: "interrupted", awaitingChildId: undefined }) + }) + + it("rejects a missing awaited-child record without creating a child", async () => { + const oldChildId = "missing-child" + const parent = { + ...parentHistoryItem, + status: "delegated" as const, + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => (id === "parent-1" ? parent : undefined)), + }) + const provider = { + getCurrentTask: vi.fn(() => makeParentTask()), + createTask: vi.fn(), + taskHistoryStore, + } as unknown as ClineProvider + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(`awaited child ${oldChildId} has status missing`) + expect(provider.createTask).not.toHaveBeenCalled() + expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled() + }) + + it("does not create a child when the provider is disposed during recovery", async () => { + const oldChildId = "old-child" + const grandchildId = "grandchild" + const parentHistory = { + ...parentHistoryItem, + status: "delegated" as const, + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } + const oldChild = { + ...parentHistoryItem, + id: oldChildId, + status: "delegated" as const, + awaitingChildId: grandchildId, + delegatedToId: grandchildId, + } + const grandchild = { ...parentHistoryItem, id: grandchildId, status: "interrupted" as const } + const records = new Map([ + ["parent-1", parentHistory], + [oldChildId, oldChild], + [grandchildId, grandchild], + ]) + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => records.get(id)), + atomicReadAndUpdate: vi.fn(async (id: string, updater: (item: HistoryItem) => HistoryItem) => { + records.set(id, updater(records.get(id)!)) + return [] + }), + }) + const parentTask = makeParentTask() + const provider = { + _disposed: false, + taskRegistry: { hasRunning: vi.fn().mockReturnValue(false) }, + getCurrentTask: vi.fn(() => parentTask), + createTask: vi.fn(), + taskHistoryStore, + log: vi.fn(), + } as unknown as ClineProvider + taskHistoryStore.atomicReadAndUpdate.mockImplementation( + async (id: string, updater: (item: HistoryItem) => HistoryItem) => { + records.set(id, updater(records.get(id)!)) + Reflect.set(provider, "_disposed", true) + return [] + }, + ) + Object.assign(provider, { + isTaskRunningInAnyProvider: ClineProvider.prototype["isTaskRunningInAnyProvider"], + refreshDelegationChain: ClineProvider.prototype["refreshDelegationChain"], + recoverDeadAwaitedChild: ClineProvider.prototype["recoverDeadAwaitedChild"], + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow("Provider was disposed during delegation") + expect(provider.createTask).not.toHaveBeenCalled() + }) + + it("does not create a child when the parent is cancelled during recovery", async () => { + const oldChildId = "old-child" + const grandchildId = "grandchild" + const parentHistory = { + ...parentHistoryItem, + status: "delegated" as const, + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } + const oldChild = { + ...parentHistoryItem, + id: oldChildId, + status: "delegated" as const, + awaitingChildId: grandchildId, + delegatedToId: grandchildId, + } + const grandchild = { ...parentHistoryItem, id: grandchildId, status: "interrupted" as const } + const records = new Map([ + ["parent-1", parentHistory], + [oldChildId, oldChild], + [grandchildId, grandchild], + ]) + const taskHistoryStore = makeStoreStub({ get: vi.fn((id: string) => records.get(id)) }) + const parentTask = makeParentTask() + const provider = { + _disposed: false, + taskRegistry: { hasRunning: vi.fn().mockReturnValue(false) }, + getCurrentTask: vi.fn(() => parentTask), + createTask: vi.fn(), + taskHistoryStore, + log: vi.fn(), + } as unknown as ClineProvider + taskHistoryStore.atomicReadAndUpdate.mockImplementation( + async (id: string, updater: (item: HistoryItem) => HistoryItem) => { + records.set(id, updater(records.get(id)!)) + parentTask.abort = true + return [] + }, + ) + Object.assign(provider, { + isTaskRunningInAnyProvider: ClineProvider.prototype["isTaskRunningInAnyProvider"], + refreshDelegationChain: ClineProvider.prototype["refreshDelegationChain"], + recoverDeadAwaitedChild: ClineProvider.prototype["recoverDeadAwaitedChild"], + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow("Parent parent-1 was cancelled during delegation") + expect(provider.createTask).not.toHaveBeenCalled() + }) + it("rejects with 'Cannot re-delegate' when the existing awaited child is still active", async () => { const oldChildId = "old-child" const activeChild = { id: oldChildId, status: "active" } as unknown as HistoryItem @@ -604,7 +1031,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { initialTodos: [], mode: "code", }), - ).rejects.toThrow("Cannot re-delegate while the awaited child is not interrupted") + ).rejects.toThrow(`awaited child ${oldChildId} has status active`) // The authoritative preflight rejects before either provider mutates its stack. expect(child.run).not.toHaveBeenCalled() diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 3d4cc47604..3043eece1f 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -9,7 +9,12 @@ import type { HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" import { LOCK_STALE_MS, safeWriteJson } from "../../utils/safeWriteJson" import { getStorageBasePath } from "../../utils/storage" -import { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" +import { + assertValidTransition, + isDeadDelegationChain, + recoverDeadDelegatedChild, + type HistoryItemStatus, +} from "./taskLifecycle" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" export { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" @@ -431,8 +436,9 @@ export class TaskHistoryStore { // are visible when evaluating chained delegations. const byId = new Map(Array.from(this.cache.values()).map((i) => [i.id, i])) - for (const [, item] of byId) { - if (item.status !== "delegated") { + for (const [, snapshotItem] of byId) { + const item = this.cache.get(snapshotItem.id) + if (item?.status !== "delegated") { continue } @@ -465,6 +471,13 @@ export class TaskHistoryStore { `[TaskHistoryStore] Reconciled orphaned delegation: task ${item.id} → active (child ${item.awaitingChildId} not found)`, ) repairsInThisPass++ + } else if (child.status === "delegated" && isDeadDelegationChain(child, (id) => byId.get(id))) { + const recoveredChild = recoverDeadDelegatedChild(item, child) + await this.upsertCore(recoveredChild) + console.warn( + `[TaskHistoryStore] Reconciled dead nested delegation: child ${child.id} → interrupted, task ${item.id} remains delegated`, + ) + repairsInThisPass++ } else if ((child.status ?? "active") === "active" && persistedActiveIds.has(child.id)) { // An active child persisted across startup cannot have a live task session // behind it. Mark it interrupted before releasing the parent's delegation diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts index e37fd1a25e..52b4dae801 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts @@ -81,6 +81,10 @@ describe("assertValidTransition", () => { expect(() => assertValidTransition("delegated", "active")).not.toThrow() }) + it("delegated → interrupted", () => { + expect(() => assertValidTransition("delegated", "interrupted")).not.toThrow() + }) + it("interrupted → completed", () => { expect(() => assertValidTransition("interrupted", "completed")).not.toThrow() }) @@ -644,27 +648,66 @@ describe("TaskHistoryStore reconcileDelegationState", () => { expect(store.get("parent-b")?.status).toBe("active") }) - it("repairs an orphaned link in a chained delegation without repairing its grandparent", async () => { - // C doesn't exist (orphaned). B is delegated waiting for C → repaired to active. - // A sees B as delegated in the persisted startup snapshot and remains delegated. - const parentA = makeItem({ id: "parent-a-chain", status: "delegated", awaitingChildId: "parent-b-chain" }) + it("recovers a delegated chain that terminates in a missing child", async () => { + const parentA = makeItem({ + id: "parent-a-chain", + status: "delegated", + awaitingChildId: "parent-b-chain", + delegatedToId: "parent-b-chain", + }) const parentB = makeItem({ id: "parent-b-chain", status: "delegated", awaitingChildId: "missing-child-chain", + delegatedToId: "missing-child-chain", + parentTaskId: parentA.id, }) await seedItems([parentA, parentB]) await store.initialize() - // B is repaired: its child (C) was missing - expect(store.get("parent-b-chain")?.status).toBe("active") - // A stays delegated: B was repaired from delegated to active and remains - // resumable rather than being mistaken for an active orphan from disk. - expect(store.get("parent-a-chain")?.status).toBe("delegated") - expect(store.get("parent-a-chain")?.awaitingChildId).toBe("parent-b-chain") - expect(store.get("parent-b-chain")?.status).toBe("active") - expect(store.get("parent-b-chain")?.awaitingChildId).toBeUndefined() + expect(store.get(parentA.id)).toMatchObject({ + status: "delegated", + awaitingChildId: parentB.id, + }) + expect(store.get(parentB.id)).toMatchObject({ + status: "interrupted", + parentTaskId: parentA.id, + awaitingChildId: undefined, + delegatedToId: undefined, + }) + }) + + it("recovers a delegated chain that terminates in an interrupted grandchild", async () => { + const parent = makeItem({ + id: "nested-parent", + status: "delegated", + awaitingChildId: "nested-child", + delegatedToId: "nested-child", + }) + const child = makeItem({ + id: "nested-child", + status: "delegated", + parentTaskId: parent.id, + awaitingChildId: "nested-grandchild", + delegatedToId: "nested-grandchild", + }) + const grandchild = makeItem({ + id: "nested-grandchild", + status: "interrupted", + parentTaskId: child.id, + }) + await seedItems([parent, child, grandchild]) + + await store.initialize() + + expect(store.get(parent.id)).toMatchObject({ status: "delegated", awaitingChildId: child.id }) + expect(store.get(child.id)).toMatchObject({ + status: "interrupted", + awaitingChildId: undefined, + delegatedToId: undefined, + }) + expect(store.get(grandchild.id)?.status).toBe("interrupted") }) it("does not repair a grandparent when replay repairs the middle node", async () => { diff --git a/src/core/task-persistence/__tests__/taskLifecycle.spec.ts b/src/core/task-persistence/__tests__/taskLifecycle.spec.ts index fe415f09f8..402bec0f93 100644 --- a/src/core/task-persistence/__tests__/taskLifecycle.spec.ts +++ b/src/core/task-persistence/__tests__/taskLifecycle.spec.ts @@ -5,6 +5,8 @@ import { completeDelegatedChild, delegateTaskToChild, interruptDelegatedChild, + isDeadDelegationChain, + recoverDeadDelegatedChild, } from "../taskLifecycle" function item(id: string, overrides: Partial = {}): HistoryItem { @@ -64,6 +66,56 @@ describe("task lifecycle transitions", () => { expect(interruptDelegatedChild(parent, child)).toMatchObject({ status: "interrupted", parentTaskId: "parent" }) }) + it("recognizes only delegated chains that terminate without a live owner", () => { + const interrupted = item("grandchild", { status: "interrupted" }) + const completed = item("completed-grandchild", { status: "completed" }) + const active = item("active-grandchild") + const child = item("child", { status: "delegated", awaitingChildId: interrupted.id }) + const tasks = new Map([interrupted, completed, active].map((task) => [task.id, task])) + + expect(isDeadDelegationChain(child, (id) => tasks.get(id))).toBe(true) + expect(isDeadDelegationChain({ ...child, awaitingChildId: completed.id }, (id) => tasks.get(id))).toBe(true) + expect(isDeadDelegationChain({ ...child, awaitingChildId: active.id }, (id) => tasks.get(id))).toBe(false) + expect(isDeadDelegationChain({ ...child, awaitingChildId: undefined }, (id) => tasks.get(id))).toBe(true) + expect( + isDeadDelegationChain( + child, + (id) => tasks.get(id), + (id) => id === child.id, + ), + ).toBe(false) + expect(isDeadDelegationChain({ ...child, status: "active" }, (id) => tasks.get(id))).toBe(false) + expect(isDeadDelegationChain({ ...child, awaitingChildId: "missing" }, (id) => tasks.get(id))).toBe(true) + expect( + isDeadDelegationChain({ ...child, awaitingChildId: child.id }, (id) => + id === child.id ? child : undefined, + ), + ).toBe(false) + }) + + it("recovers a dead delegated child without releasing its parent's ownership", () => { + const parent = item("parent", { status: "delegated", awaitingChildId: "child", delegatedToId: "child" }) + const child = item("child", { + status: "delegated", + parentTaskId: "parent", + awaitingChildId: "grandchild", + delegatedToId: "grandchild", + }) + + expect(recoverDeadDelegatedChild(parent, child)).toMatchObject({ + id: "child", + status: "interrupted", + parentTaskId: "parent", + awaitingChildId: undefined, + delegatedToId: undefined, + }) + expect(parent).toMatchObject({ status: "delegated", awaitingChildId: "child" }) + expect(() => recoverDeadDelegatedChild(parent, { ...child, status: "active" })).toThrow(/status active/) + expect(() => recoverDeadDelegatedChild({ ...parent, awaitingChildId: "other-child" }, child)).toThrow( + /not delegated to child/, + ) + }) + it("completes only the child the parent still awaits", () => { const parent = item("parent", { status: "delegated", awaitingChildId: "new-child", delegatedToId: "new-child" }) const staleChild = item("old-child", { status: "interrupted", parentTaskId: "parent" }) diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 14adeedc68..5a87cf9317 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -20,6 +20,8 @@ export { completeDelegatedChild, delegateTaskToChild, interruptDelegatedChild, + isDeadDelegationChain, + recoverDeadDelegatedChild, LifecycleTransitionError, type HistoryItemStatus, VALID_TASK_STATUS_TRANSITIONS, diff --git a/src/core/task-persistence/taskLifecycle.ts b/src/core/task-persistence/taskLifecycle.ts index efd2e1148f..ed4f77663c 100644 --- a/src/core/task-persistence/taskLifecycle.ts +++ b/src/core/task-persistence/taskLifecycle.ts @@ -5,7 +5,7 @@ export type HistoryItemStatus = NonNullable export const VALID_TASK_STATUS_TRANSITIONS: Readonly> = { active: ["delegated", "completed", "interrupted"], - delegated: ["active"], + delegated: ["active", "interrupted"], interrupted: ["completed"], completed: [], } @@ -62,6 +62,51 @@ export function interruptDelegatedChild(parent: HistoryItem, child: HistoryItem) return { ...child, status: "interrupted" } } +/** True when a delegated task has no live owner and its awaited chain ends dead. */ +export function isDeadDelegationChain( + child: HistoryItem, + getTask: (taskId: string) => HistoryItem | undefined, + isTaskLive: (taskId: string) => boolean = () => false, +): boolean { + if (child.status !== "delegated") return false + + const visited = new Set() + let current: HistoryItem | undefined = child + while (true) { + if (visited.has(current.id) || isTaskLive(current.id)) return false + visited.add(current.id) + if (current.status === "interrupted" || current.status === "completed") return true + if (current.status !== "delegated") return false + if (!current.awaitingChildId) return true + current = getTask(current.awaitingChildId) + if (!current) return true + } +} + +/** + * Recover a delegated child whose own execution chain has died. + * + * The caller must establish that neither this child nor any descendant in its + * active delegation chain has a live runtime owner. The parent deliberately + * keeps awaiting the now-interrupted child so existing resume, abandon, and + * re-delegation paths retain ownership semantics. + */ +export function recoverDeadDelegatedChild(parent: HistoryItem, child: HistoryItem): HistoryItem { + if (parent.status !== "delegated" || parent.awaitingChildId !== child.id) { + throw new LifecycleTransitionError(`Task ${parent.id} is not delegated to child ${child.id}`) + } + if (child.status !== "delegated") { + throw new LifecycleTransitionError(`Cannot recover child ${child.id} with status ${child.status}`) + } + assertValidTransition(child.status, "interrupted") + return { + ...child, + status: "interrupted", + awaitingChildId: undefined, + delegatedToId: undefined, + } +} + export function completeDelegatedChild( parent: HistoryItem, child: HistoryItem, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 495fe454b7..9dea9fb4c4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -124,6 +124,8 @@ import { abandonDelegatedChild, completeDelegatedChild, delegateTaskToChild, + isDeadDelegationChain, + recoverDeadDelegatedChild, interruptDelegatedChild, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" @@ -3834,6 +3836,58 @@ export class ClineProvider return this.currentWorkspacePath || getWorkspacePath() } + private isTaskRunningInAnyProvider(taskId: string): boolean { + return ( + this.taskRegistry.hasRunning(taskId) || + Array.from(ClineProvider.activeInstances).some( + (provider) => provider !== this && provider.taskRegistry.hasRunning(taskId), + ) + ) + } + + private async refreshDelegationChain(taskId: string): Promise { + const visited = new Set() + let currentTaskId: string | undefined = taskId + while (currentTaskId && !visited.has(currentTaskId)) { + visited.add(currentTaskId) + await this.taskHistoryStore.invalidate(currentTaskId) + const current = this.taskHistoryStore.get(currentTaskId) + currentTaskId = current?.status === "delegated" ? current.awaitingChildId : undefined + } + } + + private async recoverDeadAwaitedChild(parent: HistoryItem, childId: string): Promise { + await this.refreshDelegationChain(childId) + const child = this.taskHistoryStore.get(childId) + if ( + !child || + !isDeadDelegationChain( + child, + (id) => this.taskHistoryStore.get(id), + (id) => this.isTaskRunningInAnyProvider(id), + ) + ) { + return child + } + await this.taskHistoryStore.atomicReadAndUpdate(childId, (currentChild) => { + if ( + !isDeadDelegationChain( + currentChild, + (id) => this.taskHistoryStore.get(id), + (id) => this.isTaskRunningInAnyProvider(id), + ) + ) { + throw new Error(`Delegation chain for child ${childId} became live during recovery`) + } + return recoverDeadDelegatedChild(parent, currentChild) + }) + const recovered = this.taskHistoryStore.get(childId) + this.log( + `[delegateParentAndOpenChild] Recovered dead delegated child ${childId} as interrupted before re-delegation`, + ) + return recovered + } + /** * Delegate parent task and open child task. * @@ -3884,9 +3938,24 @@ export class ClineProvider const awaitedChildId = authoritativeParent.awaitingChildId if (!awaitedChildId) throw new Error("Cannot re-delegate a parent with no awaited child") await this.taskHistoryStore.invalidate(awaitedChildId) - if (this.taskHistoryStore.get(awaitedChildId)?.status !== "interrupted") { - throw new Error("Cannot re-delegate while the awaited child is not interrupted") + let awaitedChild = this.taskHistoryStore.get(awaitedChildId) + if (awaitedChild?.status === "delegated") { + awaitedChild = await this.recoverDeadAwaitedChild(authoritativeParent, awaitedChildId) } + if (awaitedChild?.status !== "interrupted") { + throw new Error( + `Cannot re-delegate while the awaited child ${awaitedChildId} has status ${awaitedChild?.status ?? "missing"}; expected interrupted`, + ) + } + } + if (this._disposed) { + throw new Error("[delegateParentAndOpenChild] Provider was disposed during delegation") + } + if (parent.abort || parent.abandoned) { + throw new Error(`[delegateParentAndOpenChild] Parent ${parent.taskId} was cancelled during delegation`) + } + if (this.getCurrentTask() !== parent) { + throw new Error(`[delegateParentAndOpenChild] Parent ${parent.taskId} is no longer current`) } if (pendingActionId) { const parentHistory = this.taskHistoryStore.get(parentTaskId) @@ -3966,6 +4035,16 @@ export class ClineProvider ) } + if (this._disposed) { + throw new Error("[delegateParentAndOpenChild] Provider was disposed during delegation") + } + if (parent.abort || parent.abandoned) { + throw new Error(`[delegateParentAndOpenChild] Parent ${parent.taskId} was cancelled during delegation`) + } + if (this.getCurrentTask() !== parent) { + throw new Error(`[delegateParentAndOpenChild] Parent ${parent.taskId} is no longer current`) + } + // 3) Enforce single-open invariant by closing/disposing the parent first // This ensures we never have >1 tasks open at any time during delegation. // Await abort completion to ensure clean disposal and prevent unhandled rejections. @@ -3980,6 +4059,10 @@ export class ClineProvider // Non-fatal: proceed with child creation even if parent cleanup had issues } + if (this._disposed) { + throw new Error("[delegateParentAndOpenChild] Provider was disposed during parent cleanup") + } + // 4) Bind the child directly to the delegating task's local provider // context. Delegation never mutates shared profile/global state. // Create child as sole active (parent reference preserved for lineage) @@ -4014,6 +4097,9 @@ export class ClineProvider // slip between the status snapshot and the write. An active child must never be // silently detached. try { + if (this._disposed) { + throw new Error("[delegateParentAndOpenChild] Provider was disposed before delegation commit") + } await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => { if (pendingActionId && historyItem.pendingAction?.actionId !== pendingActionId) { throw new Error( @@ -4030,6 +4116,9 @@ export class ClineProvider delegated.pendingAction?.actionId === pendingActionId ? undefined : delegated.pendingAction, } }) + if (this._disposed) { + throw new Error("[delegateParentAndOpenChild] Provider was disposed before child scheduling") + } this.recentTasksCache = undefined if (this.isViewLaunched) { const updatedItem = this.taskHistoryStore.get(parentTaskId)