From c07716ed4c823d8378d4c29febb657ae6bb6d0b5 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 2 Sep 2026 22:58:28 +0000 Subject: [PATCH 01/17] fix: keep nested subtask delegation active --- src/__tests__/provider-delegation.spec.ts | 5 +-- src/core/webview/ClineProvider.ts | 35 ++++++++++++++----- .../ClineProvider.apiHandlerRebuild.spec.ts | 35 +++++++++++++++++++ 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0b7aef8775..c934170438 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -249,8 +249,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // Provider-level event expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") - // Mode switch - expect(handleModeSwitch).toHaveBeenCalledWith("code") + // The parent has already been removed, so the mode switch must not publish a + // transient empty-task state before the child is created. + expect(handleModeSwitch).toHaveBeenCalledWith("code", undefined, { preparePendingTask: true }) }) it("posts taskHistoryItemUpdated to the webview when isViewLaunched is true", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 87a899344c..b066a37cb9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1713,15 +1713,20 @@ export class ClineProvider * @param targetTask The task whose in-memory mode should be updated. Defaults to the * current task. Pass null to apply only global mode/profile effects for a pending child. */ - public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { + public async handleModeSwitch( + newMode: Mode, + targetTask: Task | null | undefined = this.getCurrentTask(), + options: { preparePendingTask?: boolean } = {}, + ) { return this.enqueueProviderProfileMutation((signal) => - this.handleModeSwitchUnlocked(newMode, targetTask, signal), + this.handleModeSwitchUnlocked(newMode, targetTask, options, signal), ) } private async handleModeSwitchUnlocked( newMode: Mode, targetTask: Task | null | undefined, + options: { preparePendingTask?: boolean }, signal?: AbortSignal, ): Promise { const task = targetTask @@ -1761,7 +1766,7 @@ export class ClineProvider // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { - if (targetTask !== null) { + if (targetTask !== null && !options.preparePendingTask) { await this.postStateToWebview() } return @@ -1795,7 +1800,15 @@ export class ClineProvider if (hasActualSettings) { await this.activateProviderProfileUnlocked( { name: profile.name }, - targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, + targetTask === null + ? { skipCurrentTaskRebuild: true } + : options.preparePendingTask + ? { + skipCurrentTaskRebuild: true, + applyProviderSettingsToContext: true, + suppressStatePost: true, + } + : undefined, signal, ) } else { @@ -1817,7 +1830,7 @@ export class ClineProvider } } - if (targetTask !== null) { + if (targetTask !== null && !options.preparePendingTask) { await this.postStateToWebview() } } @@ -1995,6 +2008,8 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + applyProviderSettingsToContext?: boolean + suppressStatePost?: boolean }, ) { return this.enqueueProviderProfileMutation((signal) => @@ -2008,6 +2023,8 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + applyProviderSettingsToContext?: boolean + suppressStatePost?: boolean }, signal?: AbortSignal, ): Promise { @@ -2018,8 +2035,10 @@ export class ClineProvider const persistModeConfig = options?.persistModeConfig ?? true const persistTaskHistory = options?.persistTaskHistory ?? true const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false + const applyProviderSettingsToContext = options?.applyProviderSettingsToContext ?? !skipCurrentTaskRebuild + const suppressStatePost = options?.suppressStatePost ?? false - if (!skipCurrentTaskRebuild) { + if (applyProviderSettingsToContext) { // See `upsertProviderProfile` for a description of what this is doing. await Promise.all([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), @@ -2043,7 +2062,7 @@ export class ClineProvider await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild }) } - if (!skipCurrentTaskRebuild) { + if (!skipCurrentTaskRebuild && !suppressStatePost) { await this.postStateToWebview() } @@ -3902,7 +3921,7 @@ export class ClineProvider // The mode switch must happen before createTask() because the Task constructor // initializes its mode from provider.getState() during initializeTaskMode(). try { - await this.handleModeSwitch(mode as any) + await this.handleModeSwitch(mode as any, undefined, { preparePendingTask: true }) } catch (e) { this.log( `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 99d254cb9a..8ec5d8b149 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -612,6 +612,41 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledWith({ name: "ask-profile" }) }) + test("pending child preparation applies its profile without posting an empty task state", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + await provider.addClineToStack(unrelatedTask) + provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id") + provider["providerSettingsManager"].listConfig = vi + .fn() + .mockResolvedValue([{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }]) + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue({ + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + provider["providerSettingsManager"].activateProfile = vi.fn().mockResolvedValue({ + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + postStateSpy.mockClear() + + await provider["handleModeSwitchUnlocked"]("ask" as Mode, undefined, { preparePendingTask: true }) + + expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") + expect(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ openRouterModelId: "openai/gpt-4.1-mini" }), + ) + expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(postStateSpy).not.toHaveBeenCalled() + }) + test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { const mockTask = new Task({ ...defaultTaskOptions, From 010a4a5c5fb6452173e4bc8693cf7b2e7e6139d0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 00:42:50 +0000 Subject: [PATCH 02/17] fix: isolate nested child mode preparation --- src/__tests__/provider-delegation.spec.ts | 2 +- src/core/webview/ClineProvider.ts | 23 +++++----- .../ClineProvider.apiHandlerRebuild.spec.ts | 45 ++++++++++++++++++- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index c934170438..e3e32a2365 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -251,7 +251,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // The parent has already been removed, so the mode switch must not publish a // transient empty-task state before the child is created. - expect(handleModeSwitch).toHaveBeenCalledWith("code", undefined, { preparePendingTask: true }) + expect(handleModeSwitch).toHaveBeenCalledWith("code", null, { preparePendingTask: true }) }) it("posts taskHistoryItemUpdated to the webview when isViewLaunched is true", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b066a37cb9..4fa619e58f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1798,19 +1798,16 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - await this.activateProviderProfileUnlocked( - { name: profile.name }, - targetTask === null + const activationOptions = options.preparePendingTask + ? { + skipCurrentTaskRebuild: true, + applyProviderSettingsToContext: true, + suppressStatePost: true, + } + : targetTask === null ? { skipCurrentTaskRebuild: true } - : options.preparePendingTask - ? { - skipCurrentTaskRebuild: true, - applyProviderSettingsToContext: true, - suppressStatePost: true, - } - : undefined, - signal, - ) + : undefined + await this.activateProviderProfileUnlocked({ name: profile.name }, activationOptions, signal) } else { // The task will continue with the current/default configuration. } @@ -3921,7 +3918,7 @@ export class ClineProvider // The mode switch must happen before createTask() because the Task constructor // initializes its mode from provider.getState() during initializeTaskMode(). try { - await this.handleModeSwitch(mode as any, undefined, { preparePendingTask: true }) + await this.handleModeSwitch(mode, null, { preparePendingTask: true }) } catch (e) { this.log( `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 8ec5d8b149..bd0793f9b9 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -614,6 +614,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { test("pending child preparation applies its profile without posting an empty task state", async () => { const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode await provider.addClineToStack(unrelatedTask) provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id") provider["providerSettingsManager"].listConfig = vi @@ -632,11 +633,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { openRouterModelId: "openai/gpt-4.1-mini", }) const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory") const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") postStateSpy.mockClear() - await provider["handleModeSwitchUnlocked"]("ask" as Mode, undefined, { preparePendingTask: true }) + await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") expect(setProviderSettingsSpy).toHaveBeenCalledWith( @@ -644,6 +646,47 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { ) expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(unrelatedTask["_taskMode"]).toBe("code") + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + expect(postStateSpy).not.toHaveBeenCalled() + }) + + test("pending child preparation keeps the current profile when the mode has no saved profile", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode + await provider.addClineToStack(unrelatedTask) + const activateProfileSpy = vi.spyOn(provider["providerSettingsManager"], "activateProfile") + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + postStateSpy.mockClear() + + await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + expect(activateProfileSpy).not.toHaveBeenCalled() + expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(unrelatedTask["_taskMode"]).toBe("code") + expect(postStateSpy).not.toHaveBeenCalled() + }) + + test("pending child preparation preserves the locked profile without posting state", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode + await provider.addClineToStack(unrelatedTask) + vi.mocked(mockContext.workspaceState.get).mockReturnValue(true) + const getModeConfigIdSpy = vi.spyOn(provider["providerSettingsManager"], "getModeConfigId") + const activateProfileSpy = vi.spyOn(provider["providerSettingsManager"], "activateProfile") + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + postStateSpy.mockClear() + + await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(activateProfileSpy).not.toHaveBeenCalled() + expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(unrelatedTask["_taskMode"]).toBe("code") expect(postStateSpy).not.toHaveBeenCalled() }) From b60492bfdd1395907062b8ba04eeb14121fb05d3 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 01:25:47 +0000 Subject: [PATCH 03/17] test: model provider delegation handoffs --- docs/architecture/task-lifecycle-model.md | 10 + package.json | 2 +- scripts/check-provider-handoff.ts | 358 ++++++++++++++++++ src/__tests__/provider-delegation.spec.ts | 6 +- src/core/task-persistence/index.ts | 9 + src/core/task-persistence/providerHandoff.ts | 53 +++ src/core/webview/ClineProvider.ts | 61 ++- .../ClineProvider.apiHandlerRebuild.spec.ts | 15 +- 8 files changed, 495 insertions(+), 19 deletions(-) create mode 100644 scripts/check-provider-handoff.ts create mode 100644 src/core/task-persistence/providerHandoff.ts diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index d39c0fd9e2..342791ded6 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -49,6 +49,16 @@ The model has three fixed task slots, enough to cover competing siblings and a n 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. +## Provider handoff refinement model + +The same command runs `scripts/check-provider-handoff.ts`, a separate bounded model for the concrete provider steps that refine the atomic `delegate(parent, child)` lifecycle operation. It imports the production handoff policy and profile decision functions from `src/core/task-persistence/providerHandoff.ts`; its single persistence step calls `delegateTaskToChild` rather than duplicating the persisted transition. + +The model covers both a sole live parent and a nested parent whose removal exposes an unrelated root task. For each topology it checks saved, unsaved, and workspace-locked profile paths through these observable phases: remove parent, prepare child profile, create the paused child, persist delegation, start the child, and publish the child state. It enforces that pending preparation publishes no intermediate state, cannot mutate the exposed root task, creates the child with the requested mode and selected profile, and starts the child only after exactly one atomic delegation commit. + +An injected legacy policy retains the pre-fix implicit-current-task targeting and intermediate publication behavior without modifying repository history. The checker requires shortest counterexamples for both an empty publication after removing a sole parent and mutation of an exposed root during nested delegation. These witnesses are regression ratchets for the provider handoff policy, not generally allowed lifecycle states. + +This model deliberately keeps profile identities as opaque names/IDs and does not model API secrets, provider construction, VS Code transport latency, filesystem durability, scheduler fairness, or rollback cleanup. Focused provider tests remain responsible for proving that `ClineProvider` interprets the shared production policy correctly. + ## Shared-store concurrency model The same `pnpm lifecycle:model-check` command also runs a second bounded explorer over two `TaskHistoryStore` hosts. It imports the production `computeHistoryDelta` and `mergeHistoryDelta` functions, so its semantics match the store rather than assuming coherent caches or transactional pair writes: diff --git a/package.json b/package.json index 94f2d52e27..fb5497403e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts", + "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-provider-handoff.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs", "test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only", diff --git a/scripts/check-provider-handoff.ts b/scripts/check-provider-handoff.ts new file mode 100644 index 0000000000..209f3491bb --- /dev/null +++ b/scripts/check-provider-handoff.ts @@ -0,0 +1,358 @@ +import assert from "node:assert/strict" + +import type { HistoryItem } from "../packages/types/src/history" +import { + createProviderHandoffPlan, + decideProviderHandoffProfile, + type ProviderProfileRef, +} from "../src/core/task-persistence/providerHandoff" +import { delegateTaskToChild } from "../src/core/task-persistence/taskLifecycle" + +type TaskId = "root" | "parent" | "child" +type Topology = "sole-parent" | "exposed-root" +type ProfileScenario = "saved" | "unsaved" | "locked" +type Phase = + | "parent-open" + | "parent-removed" + | "profile-prepared" + | "child-created" + | "delegation-committed" + | "child-running" + | "settled" + +interface RuntimeTask { + mode: string + profile: string +} + +interface ModelState { + topology: Topology + scenario: ProfileScenario + phase: Phase + currentTaskId?: TaskId + rootTask: RuntimeTask + rootHistory: HistoryItem + parentHistory: HistoryItem + childTask?: RuntimeTask + childStarted: boolean + globalMode: string + globalProfile: string + modeProfileId?: string + publications: Array + refinementCommits: number +} + +interface ModelPolicy { + target: "none" | "implicit-current" + mutateExposedTask: boolean + publishWhilePending: boolean + applyProviderSettingsToContext: boolean +} + +interface TraceStep { + action: string + state: ModelState +} + +interface ModelResult { + states: number + traces: number + actions: Set +} + +interface Counterexample { + violation: string + trace: TraceStep[] +} + +const requestedMode = "child-mode" +const currentProfile: ProviderProfileRef = { name: "root-profile", id: "root-profile-id" } +const savedProfile: ProviderProfileRef = { name: "child-profile", id: "child-profile-id" } +const MAX_STATES = 100 +const actionOrder = [ + "remove-parent", + "prepare-profile", + "create-child", + "persist-delegation", + "start-child", + "publish-child", +] as const + +const legacyPolicy: ModelPolicy = { + target: "implicit-current", + mutateExposedTask: true, + publishWhilePending: true, + applyProviderSettingsToContext: true, +} + +function history(id: TaskId, parentTaskId?: TaskId): HistoryItem { + return { + id, + number: id === "root" ? 0 : id === "parent" ? 1 : 2, + ts: id === "root" ? 0 : id === "parent" ? 1 : 2, + task: id, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + mode: "root-mode", + parentTaskId, + rootTaskId: parentTaskId ? "root" : undefined, + childIds: [], + } +} + +function initialState(topology: Topology, scenario: ProfileScenario): ModelState { + const parentHistory = history("parent", topology === "exposed-root" ? "root" : undefined) + const rootHistory = topology === "exposed-root" ? delegateTaskToChild(history("root"), "parent") : history("root") + return { + topology, + scenario, + phase: "parent-open", + currentTaskId: "parent", + rootTask: { mode: "root-mode", profile: currentProfile.name }, + rootHistory, + parentHistory, + childStarted: false, + globalMode: "root-mode", + globalProfile: currentProfile.name, + publications: [], + refinementCommits: 0, + } +} + +function profileDecision(state: ModelState) { + return decideProviderHandoffProfile({ + locked: state.scenario === "locked", + currentProfile, + savedProfile: state.scenario === "saved" ? savedProfile : undefined, + }) +} + +function productionPolicy(): ModelPolicy { + const { policy } = createProviderHandoffPlan(requestedMode) + return { + target: policy.targetTask === null ? "none" : "implicit-current", + mutateExposedTask: policy.mutateExposedTask, + publishWhilePending: policy.publishWhilePending, + applyProviderSettingsToContext: policy.applyProviderSettingsToContext, + } +} + +function nextAction(phase: Phase): (typeof actionOrder)[number] | undefined { + switch (phase) { + case "parent-open": + return "remove-parent" + case "parent-removed": + return "prepare-profile" + case "profile-prepared": + return "create-child" + case "child-created": + return "persist-delegation" + case "delegation-committed": + return "start-child" + case "child-running": + return "publish-child" + case "settled": + return undefined + } +} + +function transition(state: ModelState, action: (typeof actionOrder)[number], policy: ModelPolicy): ModelState { + const next = structuredClone(state) + const decision = profileDecision(state) + + switch (action) { + case "remove-parent": + next.phase = "parent-removed" + next.currentTaskId = state.topology === "exposed-root" ? "root" : undefined + return next + case "prepare-profile": { + next.phase = "profile-prepared" + next.globalMode = requestedMode + if (policy.applyProviderSettingsToContext && decision.profile) { + next.globalProfile = decision.profile.name + } + if (decision.source === "unsaved-current") { + next.modeProfileId = decision.persistModeProfileId + } + if (policy.target === "implicit-current" && policy.mutateExposedTask && next.currentTaskId === "root") { + next.rootTask = { mode: requestedMode, profile: next.globalProfile } + next.rootHistory = { ...next.rootHistory, mode: requestedMode } + } + if (policy.publishWhilePending) next.publications.push(next.currentTaskId) + return next + } + case "create-child": + next.phase = "child-created" + next.currentTaskId = "child" + next.childTask = { mode: next.globalMode, profile: next.globalProfile } + return next + case "persist-delegation": + next.phase = "delegation-committed" + next.parentHistory = delegateTaskToChild(next.parentHistory, "child") + next.refinementCommits++ + return next + case "start-child": + next.phase = "child-running" + next.childStarted = true + return next + case "publish-child": + next.phase = "settled" + next.publications.push(next.currentTaskId) + return next + } +} + +function phaseAtLeast(state: ModelState, phase: Phase): boolean { + const phases: Phase[] = [ + "parent-open", + "parent-removed", + "profile-prepared", + "child-created", + "delegation-committed", + "child-running", + "settled", + ] + return phases.indexOf(state.phase) >= phases.indexOf(phase) +} + +function violations(state: ModelState): string[] { + const result: string[] = [] + const initialRoot = initialState(state.topology, state.scenario) + const decision = profileDecision(state) + const expectedProfile = decision.profile?.name ?? currentProfile.name + + if (state.publications.some((taskId) => taskId === undefined)) { + result.push("published an empty task while child handoff was pending") + } + if (state.phase !== "settled" && state.publications.length > 0) { + result.push("published state before child handoff settled") + } + if ( + JSON.stringify(state.rootTask) !== JSON.stringify(initialRoot.rootTask) || + JSON.stringify(state.rootHistory) !== JSON.stringify(initialRoot.rootHistory) + ) { + result.push("mutated the unrelated exposed root task") + } + if (phaseAtLeast(state, "profile-prepared") && state.globalProfile !== expectedProfile) { + result.push("prepared the wrong child profile") + } + if (state.scenario === "unsaved" && phaseAtLeast(state, "profile-prepared")) { + if (state.modeProfileId !== currentProfile.id) result.push("did not persist the inherited unsaved profile") + } + if (state.scenario !== "unsaved" && state.modeProfileId !== undefined) { + result.push("persisted an unexpected mode profile") + } + if (phaseAtLeast(state, "child-created")) { + if (state.childTask?.mode !== requestedMode || state.childTask.profile !== expectedProfile) { + result.push("created the child with the wrong mode or profile") + } + } + if (state.childStarted && state.refinementCommits !== 1) { + result.push("started the child before the atomic delegation commit") + } + if (phaseAtLeast(state, "delegation-committed")) { + const expectedParent = delegateTaskToChild(initialRoot.parentHistory, "child") + if (JSON.stringify(state.parentHistory) !== JSON.stringify(expectedParent)) { + result.push("delegation commit did not refine delegateTaskToChild") + } + if (state.refinementCommits !== 1) result.push("atomic delegation commit count was not exactly one") + } + if (state.phase === "settled" && state.publications.at(-1) !== "child") { + result.push("final publication did not identify the child") + } + return result +} + +function canonical(state: ModelState): string { + return JSON.stringify(state) +} + +function runModel(policy: ModelPolicy): ModelResult { + const queue: Array<{ state: ModelState; trace: TraceStep[] }> = [] + for (const topology of ["sole-parent", "exposed-root"] as const) { + for (const scenario of ["saved", "unsaved", "locked"] as const) { + const state = initialState(topology, scenario) + queue.push({ state, trace: [{ action: "initial", state }] }) + } + } + + const visited = new Set(queue.map(({ state }) => canonical(state))) + const actions = new Set() + let settledTraces = 0 + for (let index = 0; index < queue.length; index++) { + const node = queue[index]! + const found = violations(node.state) + if (found.length) throw new Error(`${found.join("; ")}\n${formatTrace(node.trace)}`) + const action = nextAction(node.state.phase) + if (!action) { + settledTraces++ + continue + } + actions.add(action) + const next = transition(node.state, action, policy) + const key = canonical(next) + if (!visited.has(key)) { + visited.add(key) + if (visited.size > MAX_STATES) { + throw new Error(`Provider handoff exploration exceeded its ${MAX_STATES}-state budget`) + } + queue.push({ state: next, trace: [...node.trace, { action, state: next }] }) + } + } + return { states: visited.size, traces: settledTraces, actions } +} + +function findCounterexample( + policy: ModelPolicy, + topology: Topology, + violationText: string, +): Counterexample | undefined { + let state = initialState(topology, "saved") + const trace: TraceStep[] = [{ action: "initial", state }] + while (true) { + const found = violations(state).find((violation) => violation === violationText) + if (found) return { violation: found, trace } + const action = nextAction(state.phase) + if (!action) return undefined + state = transition(state, action, policy) + trace.push({ action, state }) + } +} + +function formatTrace(trace: TraceStep[]): string { + return trace + .map( + ({ action, state }) => + `${action}: phase=${state.phase}, current=${state.currentTaskId ?? "none"}, rootMode=${state.rootTask.mode}, globalProfile=${state.globalProfile}, publications=${JSON.stringify(state.publications)}`, + ) + .join(" -> ") +} + +const result = runModel(productionPolicy()) +assert.deepEqual([...result.actions], actionOrder) +assert.equal(result.traces, 6) + +const emptyPublication = findCounterexample( + legacyPolicy, + "sole-parent", + "published an empty task while child handoff was pending", +) +const rootMutation = findCounterexample(legacyPolicy, "exposed-root", "mutated the unrelated exposed root task") +assert(emptyPublication) +assert(rootMutation) +assert.deepEqual( + emptyPublication.trace.map(({ action }) => action), + ["initial", "remove-parent", "prepare-profile"], +) +assert.deepEqual( + rootMutation.trace.map(({ action }) => action), + ["initial", "remove-parent", "prepare-profile"], +) + +console.log( + `Provider handoff model check passed: ${result.states} reachable states, ${result.traces} scenario traces, ${result.actions.size}/${actionOrder.length} actions reachable, 3/3 profile paths, 2/2 legacy counterexamples reproduced`, +) +console.log(`Legacy empty-publication counterexample: ${formatTrace(emptyPublication.trace)}`) +console.log(`Legacy exposed-root mutation counterexample: ${formatTrace(rootMutation.trace)}`) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index e3e32a2365..ea624dc4a4 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -5,6 +5,7 @@ import type { HistoryItem } from "@roo-code/types" import { RooCodeEventName } from "@roo-code/types" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskScheduler } from "../core/task/TaskScheduler" +import { createProviderHandoffPlan } from "../core/task-persistence/providerHandoff" const parentHistoryItem: HistoryItem = { id: "parent-1", @@ -251,7 +252,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // The parent has already been removed, so the mode switch must not publish a // transient empty-task state before the child is created. - expect(handleModeSwitch).toHaveBeenCalledWith("code", null, { preparePendingTask: true }) + const handoff = createProviderHandoffPlan("code") + expect(handleModeSwitch).toHaveBeenCalledWith(handoff.requestedMode, handoff.policy.targetTask, { + pendingHandoff: handoff.policy, + }) }) it("posts taskHistoryItemUpdated to the webview when isViewLaunched is true", async () => { diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 14adeedc68..7de3b6483c 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -14,6 +14,15 @@ export { export { taskMetadata } from "./taskMetadata" export { ensureMessageIdentifiers } from "./mergeMessageSnapshots" export { TaskHistoryStore } from "./TaskHistoryStore" +export { + createProviderHandoffPlan, + decideProviderHandoffProfile, + getProviderHandoffActivationOptions, + PRODUCTION_PROVIDER_HANDOFF_POLICY, + type ProviderHandoffPolicy, + type ProviderHandoffProfileDecision, + type ProviderProfileRef, +} from "./providerHandoff" export { abandonDelegatedChild, assertValidTransition, diff --git a/src/core/task-persistence/providerHandoff.ts b/src/core/task-persistence/providerHandoff.ts new file mode 100644 index 0000000000..03cc3d1008 --- /dev/null +++ b/src/core/task-persistence/providerHandoff.ts @@ -0,0 +1,53 @@ +export interface ProviderProfileRef { + name: string + id?: string +} + +export interface ProviderHandoffPolicy { + targetTask: null + mutateExposedTask: boolean + publishWhilePending: boolean + applyProviderSettingsToContext: boolean +} + +export const PRODUCTION_PROVIDER_HANDOFF_POLICY = { + targetTask: null, + mutateExposedTask: false, + publishWhilePending: false, + applyProviderSettingsToContext: true, +} as const satisfies ProviderHandoffPolicy + +export function createProviderHandoffPlan(requestedMode: string) { + return { + requestedMode, + policy: PRODUCTION_PROVIDER_HANDOFF_POLICY, + } as const +} + +export type ProviderHandoffProfileDecision = + | { source: "locked-current"; profile?: ProviderProfileRef } + | { source: "saved"; profile: ProviderProfileRef } + | { source: "unsaved-current"; profile?: ProviderProfileRef; persistModeProfileId?: string } + +export function decideProviderHandoffProfile(params: { + locked: boolean + currentProfile?: ProviderProfileRef + savedProfile?: ProviderProfileRef +}): ProviderHandoffProfileDecision { + const { locked, currentProfile, savedProfile } = params + if (locked) return { source: "locked-current", profile: currentProfile } + if (savedProfile) return { source: "saved", profile: savedProfile } + return { + source: "unsaved-current", + profile: currentProfile, + persistModeProfileId: currentProfile?.id, + } +} + +export function getProviderHandoffActivationOptions(policy: ProviderHandoffPolicy) { + return { + skipCurrentTaskRebuild: !policy.mutateExposedTask, + applyProviderSettingsToContext: policy.applyProviderSettingsToContext, + suppressStatePost: !policy.publishWhilePending, + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4fa619e58f..c67a21fe42 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -118,8 +118,12 @@ import { TaskHistoryStore, abandonDelegatedChild, completeDelegatedChild, + createProviderHandoffPlan, + decideProviderHandoffProfile, delegateTaskToChild, + getProviderHandoffActivationOptions, interruptDelegatedChild, + type ProviderHandoffPolicy, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" @@ -1716,7 +1720,7 @@ export class ClineProvider public async handleModeSwitch( newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask(), - options: { preparePendingTask?: boolean } = {}, + options: { pendingHandoff?: ProviderHandoffPolicy } = {}, ) { return this.enqueueProviderProfileMutation((signal) => this.handleModeSwitchUnlocked(newMode, targetTask, options, signal), @@ -1726,7 +1730,7 @@ export class ClineProvider private async handleModeSwitchUnlocked( newMode: Mode, targetTask: Task | null | undefined, - options: { preparePendingTask?: boolean }, + options: { pendingHandoff?: ProviderHandoffPolicy }, signal?: AbortSignal, ): Promise { const task = targetTask @@ -1766,7 +1770,17 @@ export class ClineProvider // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { - if (targetTask !== null && !options.preparePendingTask) { + if (options.pendingHandoff) { + const currentProfileName = this.getGlobalState("currentApiConfigName") + const decision = decideProviderHandoffProfile({ + locked: true, + currentProfile: currentProfileName ? { name: currentProfileName } : undefined, + }) + if (decision.source !== "locked-current") { + throw new Error("Expected locked child profile decision") + } + } + if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { await this.postStateToWebview() } return @@ -1798,16 +1812,21 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - const activationOptions = options.preparePendingTask - ? { - skipCurrentTaskRebuild: true, - applyProviderSettingsToContext: true, - suppressStatePost: true, - } + let profileName = profile.name + if (options.pendingHandoff) { + const decision = decideProviderHandoffProfile({ + locked: false, + savedProfile: { name: profile.name, id: profile.id }, + }) + if (decision.source !== "saved") throw new Error("Expected saved child profile decision") + profileName = decision.profile.name + } + const activationOptions = options.pendingHandoff + ? getProviderHandoffActivationOptions(options.pendingHandoff) : targetTask === null ? { skipCurrentTaskRebuild: true } : undefined - await this.activateProviderProfileUnlocked({ name: profile.name }, activationOptions, signal) + await this.activateProviderProfileUnlocked({ name: profileName }, activationOptions, signal) } else { // The task will continue with the current/default configuration. } @@ -1820,14 +1839,25 @@ export class ClineProvider if (currentApiConfigNameAfter) { const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) + let configId = config?.id + if (options.pendingHandoff) { + const decision = decideProviderHandoffProfile({ + locked: false, + currentProfile: { name: currentApiConfigNameAfter, id: config?.id }, + }) + if (decision.source !== "unsaved-current") { + throw new Error("Expected unsaved child profile decision") + } + configId = decision.persistModeProfileId + } - if (config?.id) { - await this.providerSettingsManager.setModeConfig(newMode, config.id) + if (configId) { + await this.providerSettingsManager.setModeConfig(newMode, configId) } } } - if (targetTask !== null && !options.preparePendingTask) { + if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { await this.postStateToWebview() } } @@ -3918,7 +3948,10 @@ export class ClineProvider // The mode switch must happen before createTask() because the Task constructor // initializes its mode from provider.getState() during initializeTaskMode(). try { - await this.handleModeSwitch(mode, null, { preparePendingTask: true }) + const handoff = createProviderHandoffPlan(mode) + await this.handleModeSwitch(handoff.requestedMode, handoff.policy.targetTask, { + pendingHandoff: handoff.policy, + }) } catch (e) { this.log( `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index bd0793f9b9..dfe31e1a44 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -8,6 +8,7 @@ import { getModelId, RooCodeEventName } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" import type { Mode } from "../../../shared/modes" import { Task, TaskOptions } from "../../task/Task" +import { PRODUCTION_PROVIDER_HANDOFF_POLICY } from "../../task-persistence/providerHandoff" import { ClineProvider } from "../ClineProvider" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" @@ -638,7 +639,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") postStateSpy.mockClear() - await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") expect(setProviderSettingsSpy).toHaveBeenCalledWith( @@ -655,13 +658,17 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const unrelatedTask = new Task(defaultTaskOptions) unrelatedTask["_taskMode"] = "code" as Mode await provider.addClineToStack(unrelatedTask) + await provider.contextProxy.setValue("currentApiConfigName", "test-config") const activateProfileSpy = vi.spyOn(provider["providerSettingsManager"], "activateProfile") const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) postStateSpy.mockClear() - await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + expect(provider["providerSettingsManager"].setModeConfig).toHaveBeenCalledWith("ask", "test-id") expect(activateProfileSpy).not.toHaveBeenCalled() expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() @@ -679,7 +686,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) postStateSpy.mockClear() - await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") expect(getModeConfigIdSpy).not.toHaveBeenCalled() From bcdcfdb21ffa01d02a91d1c5e55ea5c959375e78 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 22:57:44 +0000 Subject: [PATCH 04/17] fix: resolve package-local mutation tests --- .../__tests__/providerHandoff.spec.ts | 111 ++++++++++++++++++ src/core/task-persistence/index.ts | 2 + src/core/task-persistence/providerHandoff.ts | 35 ++++++ src/core/webview/ClineProvider.ts | 63 ++++------ .../ClineProvider.apiHandlerRebuild.spec.ts | 33 ++++++ 5 files changed, 203 insertions(+), 41 deletions(-) create mode 100644 src/core/task-persistence/__tests__/providerHandoff.spec.ts diff --git a/src/core/task-persistence/__tests__/providerHandoff.spec.ts b/src/core/task-persistence/__tests__/providerHandoff.spec.ts new file mode 100644 index 0000000000..55bd9663c5 --- /dev/null +++ b/src/core/task-persistence/__tests__/providerHandoff.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest" + +import { + createProviderHandoffPlan, + decideProviderHandoffProfile, + getProviderHandoffActivationOptions, + PRODUCTION_PROVIDER_HANDOFF_POLICY, + publishProviderHandoffState, + shouldPublishProviderHandoffState, + type ProviderHandoffPolicy, +} from "../providerHandoff" + +describe("provider handoff contract", () => { + it("creates a no-target, non-publishing production plan", () => { + expect(createProviderHandoffPlan("child-mode")).toEqual({ + requestedMode: "child-mode", + policy: { + targetTask: null, + mutateExposedTask: false, + publishWhilePending: false, + applyProviderSettingsToContext: true, + }, + }) + }) + + it("selects the current profile while workspace profile locking is enabled", () => { + expect( + decideProviderHandoffProfile({ + locked: true, + currentProfile: { name: "current", id: "current-id" }, + savedProfile: { name: "saved", id: "saved-id" }, + }), + ).toEqual({ source: "locked-current", profile: { name: "current", id: "current-id" } }) + expect(decideProviderHandoffProfile({ locked: true })).toEqual({ + source: "locked-current", + profile: undefined, + }) + }) + + it("selects a saved mode profile when profile locking is disabled", () => { + expect( + decideProviderHandoffProfile({ + locked: false, + currentProfile: { name: "current", id: "current-id" }, + savedProfile: { name: "saved", id: "saved-id" }, + }), + ).toEqual({ source: "saved", profile: { name: "saved", id: "saved-id" } }) + }) + + it("inherits and persists the current profile for an unsaved mode", () => { + expect( + decideProviderHandoffProfile({ + locked: false, + currentProfile: { name: "current", id: "current-id" }, + }), + ).toEqual({ + source: "unsaved-current", + profile: { name: "current", id: "current-id" }, + persistModeProfileId: "current-id", + }) + expect(decideProviderHandoffProfile({ locked: false })).toEqual({ + source: "unsaved-current", + profile: undefined, + persistModeProfileId: undefined, + }) + }) + + it("projects production and injected policies into activation options", () => { + expect(getProviderHandoffActivationOptions(PRODUCTION_PROVIDER_HANDOFF_POLICY)).toEqual({ + skipCurrentTaskRebuild: true, + applyProviderSettingsToContext: true, + suppressStatePost: true, + }) + + const unsafePolicy: ProviderHandoffPolicy = { + targetTask: null, + mutateExposedTask: true, + publishWhilePending: true, + applyProviderSettingsToContext: false, + } + expect(getProviderHandoffActivationOptions(unsafePolicy)).toEqual({ + skipCurrentTaskRebuild: false, + applyProviderSettingsToContext: false, + suppressStatePost: false, + }) + }) + + it("publishes only when a target exists and the handoff policy permits it", () => { + expect(shouldPublishProviderHandoffState(true)).toBe(true) + expect(shouldPublishProviderHandoffState(false)).toBe(false) + expect(shouldPublishProviderHandoffState(true, PRODUCTION_PROVIDER_HANDOFF_POLICY)).toBe(false) + expect( + shouldPublishProviderHandoffState(true, { + targetTask: null, + mutateExposedTask: true, + publishWhilePending: true, + applyProviderSettingsToContext: false, + }), + ).toBe(true) + }) + + it("invokes publication only when the production decision allows it", async () => { + const publish = vi.fn().mockResolvedValue(undefined) + await publishProviderHandoffState(false, undefined, publish) + await publishProviderHandoffState(true, PRODUCTION_PROVIDER_HANDOFF_POLICY, publish) + expect(publish).not.toHaveBeenCalled() + + await publishProviderHandoffState(true, undefined, publish) + expect(publish).toHaveBeenCalledOnce() + }) +}) diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 7de3b6483c..7286112074 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -19,6 +19,8 @@ export { decideProviderHandoffProfile, getProviderHandoffActivationOptions, PRODUCTION_PROVIDER_HANDOFF_POLICY, + publishProviderHandoffState, + shouldPublishProviderHandoffState, type ProviderHandoffPolicy, type ProviderHandoffProfileDecision, type ProviderProfileRef, diff --git a/src/core/task-persistence/providerHandoff.ts b/src/core/task-persistence/providerHandoff.ts index 03cc3d1008..1d790d644a 100644 --- a/src/core/task-persistence/providerHandoff.ts +++ b/src/core/task-persistence/providerHandoff.ts @@ -29,6 +29,26 @@ export type ProviderHandoffProfileDecision = | { source: "saved"; profile: ProviderProfileRef } | { source: "unsaved-current"; profile?: ProviderProfileRef; persistModeProfileId?: string } +export function decideProviderHandoffProfile(params: { + locked: true + currentProfile?: ProviderProfileRef + savedProfile?: ProviderProfileRef +}): Extract +export function decideProviderHandoffProfile(params: { + locked: false + currentProfile?: ProviderProfileRef + savedProfile: ProviderProfileRef +}): Extract +export function decideProviderHandoffProfile(params: { + locked: false + currentProfile?: ProviderProfileRef + savedProfile?: undefined +}): Extract +export function decideProviderHandoffProfile(params: { + locked: boolean + currentProfile?: ProviderProfileRef + savedProfile?: ProviderProfileRef +}): ProviderHandoffProfileDecision export function decideProviderHandoffProfile(params: { locked: boolean currentProfile?: ProviderProfileRef @@ -51,3 +71,18 @@ export function getProviderHandoffActivationOptions(policy: ProviderHandoffPolic suppressStatePost: !policy.publishWhilePending, } } + +export function shouldPublishProviderHandoffState( + targetTaskIsNotNull: boolean, + policy?: ProviderHandoffPolicy, +): boolean { + return targetTaskIsNotNull && (policy?.publishWhilePending ?? true) +} + +export async function publishProviderHandoffState( + targetTaskIsNotNull: boolean, + policy: ProviderHandoffPolicy | undefined, + publish: () => Promise, +): Promise { + if (shouldPublishProviderHandoffState(targetTaskIsNotNull, policy)) await publish() +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c67a21fe42..9e4c657648 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -123,6 +123,7 @@ import { delegateTaskToChild, getProviderHandoffActivationOptions, interruptDelegatedChild, + publishProviderHandoffState, type ProviderHandoffPolicy, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" @@ -1770,19 +1771,9 @@ export class ClineProvider // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { - if (options.pendingHandoff) { - const currentProfileName = this.getGlobalState("currentApiConfigName") - const decision = decideProviderHandoffProfile({ - locked: true, - currentProfile: currentProfileName ? { name: currentProfileName } : undefined, - }) - if (decision.source !== "locked-current") { - throw new Error("Expected locked child profile decision") - } - } - if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { - await this.postStateToWebview() - } + await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => + this.postStateToWebview(), + ) return } @@ -1812,15 +1803,12 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - let profileName = profile.name - if (options.pendingHandoff) { - const decision = decideProviderHandoffProfile({ - locked: false, - savedProfile: { name: profile.name, id: profile.id }, - }) - if (decision.source !== "saved") throw new Error("Expected saved child profile decision") - profileName = decision.profile.name - } + const profileName = options.pendingHandoff + ? decideProviderHandoffProfile({ + locked: false, + savedProfile: { name: profile.name, id: profile.id }, + }).profile.name + : profile.name const activationOptions = options.pendingHandoff ? getProviderHandoffActivationOptions(options.pendingHandoff) : targetTask === null @@ -1837,29 +1825,22 @@ export class ClineProvider // If no saved config for this mode, save current config as default. const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") - if (currentApiConfigNameAfter) { - const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) - let configId = config?.id - if (options.pendingHandoff) { - const decision = decideProviderHandoffProfile({ + const config = listApiConfig.find((candidate) => candidate.name === currentApiConfigNameAfter) + const configId = options.pendingHandoff + ? decideProviderHandoffProfile({ locked: false, - currentProfile: { name: currentApiConfigNameAfter, id: config?.id }, - }) - if (decision.source !== "unsaved-current") { - throw new Error("Expected unsaved child profile decision") - } - configId = decision.persistModeProfileId - } - - if (configId) { - await this.providerSettingsManager.setModeConfig(newMode, configId) - } + currentProfile: currentApiConfigNameAfter + ? { name: currentApiConfigNameAfter, id: config?.id } + : undefined, + }).persistModeProfileId + : config?.id + + if (configId) { + await this.providerSettingsManager.setModeConfig(newMode, configId) } } - if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { - await this.postStateToWebview() - } + await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => this.postStateToWebview()) } // Provider Profile Management diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index dfe31e1a44..8598a7c437 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -644,6 +644,11 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") + expect(setValueSpy).toHaveBeenCalledWith( + "listApiConfigMeta", + expect.arrayContaining([expect.objectContaining({ name: "ask-profile", id: "ask-id" })]), + ) + expect(setValueSpy.mock.calls.filter(([key]) => key === "listApiConfigMeta")).toHaveLength(2) expect(setProviderSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ openRouterModelId: "openai/gpt-4.1-mini" }), ) @@ -659,6 +664,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { unrelatedTask["_taskMode"] = "code" as Mode await provider.addClineToStack(unrelatedTask) await provider.contextProxy.setValue("currentApiConfigName", "test-config") + provider["providerSettingsManager"].listConfig = vi.fn().mockResolvedValue([ + { name: "other-config", id: "other-id", apiProvider: providerIdentifiers.openrouter }, + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.openrouter }, + ]) const activateProfileSpy = vi.spyOn(provider["providerSettingsManager"], "activateProfile") const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) postStateSpy.mockClear() @@ -676,6 +685,24 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(postStateSpy).not.toHaveBeenCalled() }) + test("pending child preparation leaves an unsaved mode unassigned without a current profile", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode + await provider.addClineToStack(unrelatedTask) + await provider.contextProxy.setValue("currentApiConfigName", undefined) + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + postStateSpy.mockClear() + + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) + + expect(provider["providerSettingsManager"].setModeConfig).not.toHaveBeenCalled() + expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(unrelatedTask["_taskMode"]).toBe("code") + expect(postStateSpy).not.toHaveBeenCalled() + }) + test("pending child preparation preserves the locked profile without posting state", async () => { const unrelatedTask = new Task(defaultTaskOptions) unrelatedTask["_taskMode"] = "code" as Mode @@ -697,6 +724,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() expect(unrelatedTask["_taskMode"]).toBe("code") expect(postStateSpy).not.toHaveBeenCalled() + + await provider.handleModeSwitch("architect" as Mode, null) + expect(postStateSpy).not.toHaveBeenCalled() + + await provider.handleModeSwitch("architect" as Mode, unrelatedTask) + expect(postStateSpy).toHaveBeenCalledOnce() }) test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { From b10c5569aa563b710ae9679c7e4a64d0e66a299a Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 23:06:42 +0000 Subject: [PATCH 05/17] test: cover provider handoff mutations --- ...ec.ts => ClineProvider.delegation.spec.ts} | 2 +- .../ClineProvider.apiHandlerRebuild.spec.ts | 28 +++++++++++++++++++ src/eslint-suppressions.json | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) rename src/__tests__/{provider-delegation.spec.ts => ClineProvider.delegation.spec.ts} (99%) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts similarity index 99% rename from src/__tests__/provider-delegation.spec.ts rename to src/__tests__/ClineProvider.delegation.spec.ts index ea624dc4a4..469e1d6315 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -1,4 +1,4 @@ -// npx vitest run __tests__/provider-delegation.spec.ts +// npx vitest run __tests__/ClineProvider.delegation.spec.ts import { describe, it, expect, vi } from "vitest" import type { HistoryItem } from "@roo-code/types" diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 8598a7c437..1f16a63213 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -652,6 +652,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(setProviderSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ openRouterModelId: "openai/gpt-4.1-mini" }), ) + expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledWith({ name: "ask-profile" }) expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() expect(unrelatedTask["_taskMode"]).toBe("code") @@ -659,6 +660,20 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(postStateSpy).not.toHaveBeenCalled() }) + test("pending child preparation tolerates a current profile missing from configuration metadata", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + await provider.addClineToStack(unrelatedTask) + await provider.contextProxy.setValue("currentApiConfigName", "missing-config") + + await expect( + provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }), + ).resolves.toBeUndefined() + + expect(provider["providerSettingsManager"].setModeConfig).not.toHaveBeenCalled() + }) + test("pending child preparation keeps the current profile when the mode has no saved profile", async () => { const unrelatedTask = new Task(defaultTaskOptions) unrelatedTask["_taskMode"] = "code" as Mode @@ -775,6 +790,19 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect((mockTask as any).apiConfiguration.rateLimitSeconds).toBe(7) }) + test("suppresses only explicitly suppressed profile state posts", async () => { + const mockTask = new Task(defaultTaskOptions) + await provider.addClineToStack(mockTask) + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + postStateSpy.mockClear() + + await provider.activateProviderProfile({ name: "test-config" }, { suppressStatePost: true }) + expect(postStateSpy).not.toHaveBeenCalled() + + await provider.activateProviderProfile({ name: "test-config" }) + expect(postStateSpy).toHaveBeenCalledOnce() + }) + test("calls updateApiConfiguration when provider changes and syncs task.apiConfiguration", async () => { const mockTask = new Task({ ...defaultTaskOptions, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 381cf0c1e0..09d1c67a78 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -44,7 +44,7 @@ "count": 9 } }, - "__tests__/provider-delegation.spec.ts": { + "__tests__/ClineProvider.delegation.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 9 } From c9773a7707a3f2b5b13177c8e0134cd095f4fb77 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 5 Sep 2026 16:51:29 +0000 Subject: [PATCH 06/17] fix: fail closed when delegation mode switch rejects --- .../ClineProvider.delegation.spec.ts | 52 +++++++- src/core/webview/ClineProvider.ts | 46 +++---- .../ClineProvider.apiHandlerRebuild.spec.ts | 121 +++++++++++++++++- 3 files changed, 193 insertions(+), 26 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 469e1d6315..087ec3f919 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -130,6 +130,48 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(current).toMatchObject({ status: "delegated", awaitingChildId: "child-1" }) }) + it("fails closed when handleModeSwitch rejects: parent stays current and no child is created", async () => { + const parentTask = makeParentTask() + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn() + const handleModeSwitch = vi.fn().mockRejectedValue(new Error("mode switch failed")) + const providerEmit = vi.fn() + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack, + createTask, + handleModeSwitch, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow("mode switch failed") + + // Fail closed before the stack changes: the parent was never removed, so it + // remains the current task. + expect(removeClineFromStack).not.toHaveBeenCalled() + expect(provider.getCurrentTask()).toBe(parentTask) + + // No child was created (so none was scheduled) and no parent delegation + // metadata was committed. + expect(createTask).not.toHaveBeenCalled() + expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled() + expect(providerEmit).not.toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", expect.anything()) + }) + it("rolls back when pending-action ownership changes before the atomic parent update", async () => { const pendingAction = { kind: "create_subtask" as const, @@ -334,7 +376,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { callOrder.push("createTask") return { taskId: "child-1", start: vi.fn(), run: childRun } }) - const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + const handleModeSwitch = vi.fn(async () => { + callOrder.push("handleModeSwitch") + }) const taskHistoryStore = makeStoreStub({ atomicReadAndUpdate: vi.fn(async (_taskId: string, _updater: (h: HistoryItem) => HistoryItem) => { callOrder.push("atomicReadAndUpdate") @@ -363,8 +407,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) await Promise.resolve() // drain scheduler microtask so child.run() is invoked - // createTask → atomicReadAndUpdate → child.run: scheduler admits child only after metadata is persisted - expect(callOrder).toEqual(["createTask", "atomicReadAndUpdate", "child.run"]) + // handleModeSwitch → createTask → atomicReadAndUpdate → child.run: the mode + // handoff completes before the parent leaves the stack, and the scheduler + // admits the child only after metadata is persisted + expect(callOrder).toEqual(["handleModeSwitch", "createTask", "atomicReadAndUpdate", "child.run"]) }) it("implicitly severs interrupted awaited child and re-delegates when parent is already delegated", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9e4c657648..58836271e9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3846,6 +3846,9 @@ export class ClineProvider * - Persist parent delegation metadata * - Emit TaskDelegated (task-level; API forwards to provider/bridge) * - Create child as sole active and switch mode to child's mode + * - Fail closed if the mode-switch handoff rejects: the parent is never + * removed from the stack, so it stays the current, active task and no + * child is created or scheduled */ public async delegateParentAndOpenChild(params: { parentTaskId: string @@ -3910,7 +3913,23 @@ export class ClineProvider ) } - // 3) Enforce single-open invariant by closing/disposing the parent first + // 3) Switch provider mode to child's requested mode BEFORE disposing the parent. + // This is a null-target, non-publishing handoff (see + // PRODUCTION_PROVIDER_HANDOFF_POLICY): it applies only global mode/profile + // state and never mutates or publishes the current task, so running it while + // the parent is still focused is safe. Performing it first makes delegation + // fail closed: if the mode switch rejects, we abort before the parent is + // removed from the stack, so the parent remains the current, active task and + // no child is created or scheduled. + // The mode switch must also happen before createTask() because the Task + // constructor initializes its mode from provider.getState() during + // initializeTaskMode(). + const handoff = createProviderHandoffPlan(mode) + await this.handleModeSwitch(handoff.requestedMode, handoff.policy.targetTask, { + pendingHandoff: handoff.policy, + }) + + // 4) 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. try { @@ -3924,24 +3943,7 @@ export class ClineProvider // Non-fatal: proceed with child creation even if parent cleanup had issues } - // 3) Switch provider mode to child's requested mode BEFORE creating the child task - // This ensures the child's system prompt and configuration are based on the correct mode. - // The mode switch must happen before createTask() because the Task constructor - // initializes its mode from provider.getState() during initializeTaskMode(). - try { - const handoff = createProviderHandoffPlan(mode) - await this.handleModeSwitch(handoff.requestedMode, handoff.policy.targetTask, { - pendingHandoff: handoff.policy, - }) - } catch (e) { - this.log( - `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ - (e as Error)?.message ?? String(e) - }`, - ) - } - - // 4) Create child as sole active (parent reference preserved for lineage) + // 5) Create child as sole active (parent reference preserved for lineage) // Pass initialStatus: "active" to ensure the child task's historyItem is created // with status from the start, avoiding race conditions where the task might // call attempt_completion before status is persisted separately. @@ -3958,7 +3960,7 @@ export class ClineProvider startTask: false, }) - // 5) Persist parent delegation metadata BEFORE the child starts writing. + // 6) Persist parent delegation metadata BEFORE the child starts writing. // atomicReadAndUpdate reads from the in-memory cache and writes back within a // single lock acquisition — no concurrent writer can slip between the read and // write, and the pure updater cannot re-enter the lock (no deadlock). @@ -4036,10 +4038,10 @@ export class ClineProvider throw err } - // 6) Start the child task now that parent metadata is safely persisted. + // 7) Start the child task now that parent metadata is safely persisted. scheduleTask(this.taskScheduler, child, "delegateParentAndOpenChild") - // 7) Emit TaskDelegated (provider-level) + // 8) Emit TaskDelegated (provider-level) try { this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) } catch { diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 1f16a63213..dc7e8e1617 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" -import { getModelId, RooCodeEventName } from "@roo-code/types" +import { getModelId, RooCodeEventName, type HistoryItem } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" import type { Mode } from "../../../shared/modes" @@ -882,6 +882,125 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) }) + describe("delegateParentAndOpenChild - nested root handoff", () => { + test("real mode-switch handoff publishes no state and leaves the exposed root task untouched", async () => { + // Nested registry topology: root at the bottom, parent focused on top. + const rootTask = new Task(defaultTaskOptions) + Object.defineProperty(rootTask, "taskId", { value: "root-task-id" }) + rootTask["_taskMode"] = "code" as Mode + rootTask["_taskApiConfigName"] = "test-config" + + const parentTask = new Task(defaultTaskOptions) + Object.defineProperty(parentTask, "taskId", { value: "parent-task-id" }) + parentTask["_taskMode"] = "code" as Mode + Object.defineProperty(parentTask, "flushPendingToolResultsToHistory", { + value: vi.fn().mockResolvedValue(true), + }) + + await provider.addClineToStack(rootTask) + await provider.addClineToStack(parentTask) + expect(provider.getCurrentTask()).toBe(parentTask) + + // External system only: the store executes the delegation updater and + // returns the parent and root histories. + const parentHistory: HistoryItem = { + id: "parent-task-id", + number: 2, + ts: 2, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + mode: "code", + childIds: [], + } + const rootHistory: HistoryItem = { + id: "root-task-id", + number: 1, + ts: 1, + task: "Root", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + mode: "code", + childIds: ["parent-task-id"], + } + const atomicUpdateSpy = vi + .spyOn(provider.taskHistoryStore, "atomicReadAndUpdate") + .mockImplementation(async (_taskId: string, updater: (current: HistoryItem) => HistoryItem) => [ + updater(parentHistory), + rootHistory, + ]) + + // createTask double: an inert child whose insertion reproduces the real + // stack transition through the real addClineToStack. + const child = new Task({ ...defaultTaskOptions }) + Object.defineProperty(child, "taskId", { value: "child-task-id" }) + child["_taskMode"] = "code" as Mode + Object.defineProperty(child, "run", { value: vi.fn().mockResolvedValue(undefined) }) + const createTaskSpy = vi.spyOn(provider, "createTask").mockImplementation(async () => { + await provider.addClineToStack(child) + return child + }) + + // Snapshot the newly exposed root task before delegation. + const rootTaskModeBefore = rootTask["_taskMode"] + const rootApiConfigurationBefore = rootTask.apiConfiguration + const rootStickyProfileBefore = rootTask["_taskApiConfigName"] + const rootClineMessagesBefore = rootTask.clineMessages + const rootApiHistoryBefore = rootTask.apiConversationHistory + + // Spy without replacing the implementation: the pending handoff must not + // publish any state. + const postStateSpy = vi.spyOn(provider, "postStateToWebview") + + // Exercise the real handleModeSwitch/handleModeSwitchUnlocked path with + // profile activation for the child's mode. + provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("test-id") + + const childResult = await provider.delegateParentAndOpenChild({ + parentTaskId: "parent-task-id", + message: "Do child work", + initialTodos: [], + mode: "ask", + }) + // Drain the fire-and-forget scheduler so the inert child start settles. + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(childResult).toBe(child) + expect(createTaskSpy).toHaveBeenCalledWith("Do child work", undefined, parentTask, { + initialTodos: [], + initialStatus: "active", + startTask: false, + }) + expect(atomicUpdateSpy).toHaveBeenCalledTimes(1) + + // The real mode switch applied the child's mode globally without a single + // state publication during the entire delegation. + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + expect(postStateSpy).not.toHaveBeenCalled() + + // The stack transitioned parent -> child through the real addClineToStack, + // exposing the root beneath. + expect(provider.getCurrentTaskStack()).toEqual(["root-task-id", "child-task-id"]) + expect(provider.getCurrentTask()).toBe(child) + + // The exposed root task kept its identity and values: no mode, profile, + // API configuration, or history mutation. + expect(rootTask["_taskMode"]).toBe(rootTaskModeBefore) + expect(rootTask.apiConfiguration).toBe(rootApiConfigurationBefore) + expect(rootTask.apiConfiguration).toEqual(rootApiConfigurationBefore) + expect(rootTask["_taskApiConfigName"]).toBe(rootStickyProfileBefore) + expect(rootTask.clineMessages).toBe(rootClineMessagesBefore) + expect(rootTask.apiConversationHistory).toBe(rootApiHistoryBefore) + expect(rootTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(rootTask.setTaskApiConfigName).not.toHaveBeenCalled() + }) + }) + describe("profile switching sequence", () => { test("A -> B -> A updates task.apiConfiguration each time", async () => { const mockTask = new Task({ From b7dfd65f5baddc51edbe88c9d115df33cfe959b5 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Mon, 7 Sep 2026 02:48:46 +0000 Subject: [PATCH 07/17] fix(delegation): make provider handoff transaction-safe --- docs/architecture/task-lifecycle-model.md | 139 +- scripts/check-provider-handoff.ts | 963 ++++-- .../ClineProvider.delegation.spec.ts | 2605 ++++++++++++++++- src/__tests__/helpers/provider-stub.ts | 12 + .../history-resume-delegation.spec.ts | 76 +- src/core/config/ProviderSettingsManager.ts | 152 +- .../__tests__/ProviderSettingsManager.spec.ts | 258 ++ .../config/__tests__/importExport.spec.ts | 153 +- src/core/config/importExport.ts | 49 +- src/core/task-persistence/TaskHistoryStore.ts | 106 +- .../__tests__/TaskHistoryStore.spec.ts | 179 +- .../__tests__/providerHandoff.spec.ts | 736 +++++ src/core/task-persistence/index.ts | 34 +- src/core/task-persistence/providerHandoff.ts | 558 ++++ src/core/task/Task.ts | 92 + src/core/task/__tests__/Task.spec.ts | 75 + src/core/webview/ClineProvider.ts | 1588 +++++++++- .../ClineProvider.apiHandlerRebuild.spec.ts | 452 ++- .../webview/__tests__/ClineProvider.spec.ts | 23 + .../ClineProvider.taskHistory.spec.ts | 132 + src/eslint-suppressions.json | 8 +- src/utils/advisoryFileLock.ts | 95 + src/utils/safeWriteJson.ts | 68 +- 23 files changed, 7969 insertions(+), 584 deletions(-) create mode 100644 src/utils/advisoryFileLock.ts diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 342791ded6..cc0deee223 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -6,13 +6,14 @@ Zoo Code checks task lifecycle protocols through one compositional verification pnpm lifecycle:model-check ``` -The command runs five independent bounded submodels in sequence: +The command runs six independent bounded submodels in sequence: 1. the persisted task delegation lifecycle; -2. shared-store concurrency across task-history hosts; -3. the task cleanup protocol; -4. request-stream parser scoping; and -5. completion persistence. +2. the provider handoff transaction; +3. shared-store concurrency across task-history hosts; +4. the task cleanup protocol; +5. request-stream parser scoping; and +6. completion persistence. This umbrella command is the single model-check entry point in the `compile` CI job after type checking. Command-level composition does not merge the submodels' state spaces: each checker retains its own bounds, transitions, invariant ownership, reachability requirements, and counterexample format. In particular, parser state is not part of the persisted lifecycle graph. The focused parser checker remains directly runnable with `pnpm parser-scope:model-check` for debugging. @@ -25,13 +26,17 @@ Executable cross-model composition should be added only when a correctness claim The models use small explicit-state explorers rather than adding Quint, TLA+/TLC, or Alloy. This is deliberate: - Zoo's current risks are finite safety properties over a small persisted state machine, not yet temporal liveness or fairness properties. -- The delegation and shared-store explorers call production transition functions from `src/core/task-persistence`. `ClineProvider` uses those same functions inside serialized and atomic store operations, reducing specification drift for those protocols. +- The delegation, provider-handoff, and shared-store explorers call production transition functions from `src/core/task-persistence`. `ClineProvider` uses those same functions inside serialized and atomic store operations, reducing specification drift for those protocols. - Breadth-first exploration gives a deterministic, shortest-by-event counterexample with no Java or separate specification toolchain. - Bounds and budget exhaustion are explicit. CI never reports a truncated exploration as a pass. -This follows the same initial-state, next-state, reachable-state, invariant structure described by the [TLA+ high-level view](https://lamport.azurewebsites.net/tla/high-level-view.html) and [Quint's model-checker documentation](https://quint-lang.org/docs/model-checkers). The implementation connection is important: Quint's [model-based testing guidance](https://quint-lang.org/docs/model-based-testing) notes that checking a specification alone does not show that production code implements it. +Breadth-first exploration gives a deterministic, shortest-by-event counterexample. It requires no Java and no separate specification toolchain. Bounds and budget exhaustion are explicit. CI never reports a truncated exploration as a pass. -TLA+/PlusCal or Quint with TLC becomes a better fit when the lifecycle needs temporal properties, fairness assumptions, unbounded queues, or refinement between protocol layers. Alloy is better suited if relational ownership structure becomes harder than event ordering; Alloy analyses are explicitly bounded by scope, as described in the [Alloy tutorial](https://alloytools.org/tutorials/online/maintext-FS-1.html). Randomized model-based testing can complement, but not replace, the exhaustive bounded check when a production adapter is available; [fast-check documents command models](https://fast-check.dev/docs/advanced/model-based-testing/) and [controlled Promise scheduling](https://fast-check.dev/docs/advanced/race-conditions/). Jepsen-style history checking remains useful for distributed persistence behavior, but is heavier than this in-process lifecycle protocol; see Jepsen's [consistency model overview](https://jepsen.io/consistency). +The model follows the initial-state, next-state, reachable-state, invariant structure from the [TLA+ high-level view](https://lamport.azurewebsites.net/tla/high-level-view.html) and [Quint's model-checker documentation](https://quint-lang.org/docs/model-checkers). Quint's [model-based testing guidance](https://quint-lang.org/docs/model-based-testing) notes that checking a specification alone does not show that production code implements it. + +Use TLA+/PlusCal or Quint with TLC when the lifecycle needs temporal properties, fairness assumptions, unbounded queues, or refinement between protocol layers. Use Alloy when relational ownership structure is harder to reason about than event ordering. Alloy analyses are bounded by scope; see the [Alloy tutorial](https://alloytools.org/tutorials/online/maintext-FS-1.html). + +Randomized model-based testing can complement, but not replace, the bounded exhaustive check when a production adapter is available. [fast-check](https://fast-check.dev/docs/advanced/model-based-testing/) documents command models and [controlled Promise scheduling](https://fast-check.dev/docs/advanced/race-conditions/). For distributed persistence behavior, use Jepsen-style history checking; see Jepsen's [consistency model overview](https://jepsen.io/consistency). ## Production mapping @@ -45,23 +50,89 @@ 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. This covers competing siblings and a nested parent-child-grandchild chain. The checker explores every reachable interleaving to depth 12 and deduplicates canonical states. + +Representative checks also test rejected operations that do not create a new state. These include: 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 keep interrupted-child re-delegation and nested delegation 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. +Production completion also accepts a recovery-compatible `active` parent that still awaits the returning child. It then clears the stale pointers. Normal model transitions never create that intermediate state. A focused reducer test covers it instead of admitting it as a generally valid reachable state. ## Provider handoff refinement model -The same command runs `scripts/check-provider-handoff.ts`, a separate bounded model for the concrete provider steps that refine the atomic `delegate(parent, child)` lifecycle operation. It imports the production handoff policy and profile decision functions from `src/core/task-persistence/providerHandoff.ts`; its single persistence step calls `delegateTaskToChild` rather than duplicating the persisted transition. +The same command runs `scripts/check-provider-handoff.ts`. This is a bounded model of the delegation handoff transaction. The transaction protocol is a pure, secret-free state machine in `src/core/task-persistence/providerHandoff.ts`. `ClineProvider.delegateParentAndOpenChild` advances it at semantic landmarks. The checker explores it exhaustively. The model imports the production reducer, handoff policy, and profile decision functions. Its persistence step calls `delegateTaskToChild` instead of duplicating the persisted transition. + +The full delegation transition runs inside the per-parent `runDelegationTransition` lock. The transition covers validation, preparation, parent removal, child creation, commit, reconciliation, rollback, activation, and child start. Completion and abandonment use the same lock. Two same-parent delegations, or a completion/abandonment racing a delegation, can never interleave. + +Each transition holds an opaque owner token for its parent. Paths that run while the lock is already held — parent restoration after child creation failure, rollback restoration, `reopenParentFromDelegation` — pass the token through `createTaskWithHistoryItem` or `evictCurrentTask`. A same-parent interruption triggered by that eviction runs its unlocked core instead of re-acquiring the lock it already owns. Transitions for other parents and every external eviction still acquire locks normally. Reentrancy is explicit and per-parent, never a global lock bypass. + +Production order is prepare-before-remove. + +While the parent is still the current task, handoff preparation is read-only. It runs off the provider profile mutation queue. Preparation captures the requested mode, an explicit profile projection intent (`preserve | set{name} | clear`), and a deep-cloned API configuration into one context. It performs zero writes. A hung or timed-out queued mutation can never block delegation preparation. + +If preparation rejects, delegation aborts fail-closed. The parent stays current. + +After preparation, the parent is removed. The paused child is created from the prepared all-or-none execution context. The context is validated for completeness at runtime in `ClineProvider` and the `Task` constructor. The delegation is then durably committed through `TaskHistoryStore.atomicReadAndUpdate`. + +That atomic commit is the single lifecycle commit boundary. Legacy global state, the profile store, and publication are best-effort projections. They run strictly after the commit. They can never undo the commit or block the child from starting. + +The child is derived entirely from the resolved handoff configuration. This includes profile-derived constructor inputs such as `consecutiveMistakeLimit`. The pre-handoff global configuration can never leak into child execution. + +After the commit, context activation moves execution-context authority to the child. The child's task-local mode, sticky profile, and API configuration are then authoritative. A stale legacy projection cannot change them. + +The child starts immediately. It never waits for the legacy projection. The post-commit projection runs as fire-and-forget background work outside the per-parent delegation lock and outside the child-start critical path. A handled promise reports one named result per operation, in one of three boundaries: `profile-store` (durable profile reads/writes), `context-proxy` (legacy global state), or `queue` (the bounded queue abandoned the batch before completion). + +Completion or failure updates the generation-stamped stale-projection marker. It emits the mode-change signal only while the projection's mutation generation is still current. A superseded projection's completion is inert. On failure, the marker makes publication derive child values from the prepared context. A later successful mode/profile mutation carries a higher generation, supersedes the marker, and publication returns the user's values. + +The profile projection intent is explicit. Three values are possible: + +- `set`: the identity is written to the durable profile store and legacy global state. +- `preserve`: a locked handoff (workspace profile pinning) never rewrites the pinned identity. +- `clear`: the child's sticky profile stays `undefined`. The durable store identity is removed. Legacy global `currentApiConfigName` is written as `undefined`, never skipped. Publication shows the explicit absence instead of falling back to the `"default"` identity. + +`getState` and `getStateToPostToWebview` preserve that explicit clear for the current child and for a stale cleared projection. Legacy behavior outside an explicit clear is unchanged. + +Failure handling is coarse and labeled. + +Preparation failure aborts cleanly. Nothing needs to be undone. Child-creation failure rolls back by restoring the parent. Rollback failures (`child-cleanup`, `parent-restoration`) are recorded on a degraded-abort terminal. -The model covers both a sole live parent and a nested parent whose removal exposes an unrelated root task. For each topology it checks saved, unsaved, and workspace-locked profile paths through these observable phases: remove parent, prepare child profile, create the paused child, persist delegation, start the child, and publish the child state. It enforces that pending preparation publishes no intermediate state, cannot mutate the exposed root task, creates the child with the requested mode and selected profile, and starts the child only after exactly one atomic delegation commit. +A failed commit attempt has ambiguous durability. The write may have persisted before the failure appeared. Production reconciles this while still holding the per-parent lock. Before the update attempt, the checker captures the commit-owned parent fields as a preimage: `status`, `awaitedChildId`, `childIds`, and `pendingAction` ownership. After a rejection, the parent record is re-read strictly from disk through `TaskHistoryStore.readFresh`. This call distinguishes `found`, definitively missing, and unreadable or incompatible records. It does not collapse every read failure into a cache miss. -An injected legacy policy retains the pre-fix implicit-current-task targeting and intermediate publication behavior without modifying repository history. The checker requires shortest counterexamples for both an empty publication after removing a sole parent and mutation of an exposed root during nested delegation. These witnesses are regression ratchets for the provider handoff policy, not generally allowed lifecycle states. +Child history is optional at this boundary. A parent record durably delegated to the attempted child is observed committed even with no child record. A present child record that contradicts the lineage degrades the observation. -This model deliberately keeps profile identities as opaque names/IDs and does not model API secrets, provider construction, VS Code transport latency, filesystem durability, scheduler fairness, or rollback cleanup. Focused provider tests remain responsible for proving that `ClineProvider` interprets the shared production policy correctly. +A parent record that exactly matches the safe preimage is observed uncommitted. The rollback then proceeds to a clean abort. + +Every other observation is incoherent: a delegation to a different child (`other-child`), a contradictory child record (`contradictory-child`), a record that matches neither the delegation nor the preimage (`drifted`), a missing parent (`missing`), or an unreadable one (`unreadable`). An incoherent terminal degrades without any destructive step. The child stays paused. The parent record is never restored over potentially committed lineage. The caller receives an `AggregateError` that keeps the original error first. + +The labels are diagnostics only. Safety depends solely on continuing for `exact` and rolling back for `unchanged`. Rollback steps run at most once and never before the durability observation. + +Rejected orderings include: remove-before-prepare, create-before-remove, commit-before-child, context authority before commit, publication before a durable commit plus activation plus start, rollback during an unresolved commit, and any rollback after a committed delegation. + +Queue liveness is bounded with an admission fence. Queue ownership distinguishes admission from execution. A caller whose operation times out after 30 seconds is always released. + +If the timeout fires before the queued function was admitted, the cancellation aborts the signal. The abandoned function performs zero writes when it eventually runs. The queue tail advances only to the previous tail. Later operations still wait for every earlier started write. + +If the timeout fires after the function started, the queue tail stays owned until the underlying operation settles. Existing storage writes are not cancellable. Releasing the queue would let a newer write interleave with, or physically serialize behind, the still-running older one. Later profile writes stay serialized behind a started hung write. The caller timeout is a liveness guarantee for callers, not for the queue. + +Every queued function checks its abort signal before each write. An abandoned (cancel-before-start) operation performs no writes. A late completion after abort is inert: its outcome is discarded and it cannot clear a newer generation's marker. + +The model covers a sole live parent and a nested parent whose removal exposes an unrelated root task. Each path covers saved, unsaved, and workspace-locked profile states. + +Invariants require: no pre-commit projection mutation, exactly one commit, one prepared generation binding child creation and authority, no unrelated-root mutation, and no empty or intermediate publication. + +An injected legacy driver keeps the pre-fix remove-then-prepare flow with implicit-current-task targeting. The checker requires shortest counterexamples for both the empty publication after removing a sole parent and mutation of an exposed root. These are regression ratchets, not generally allowed states. + +Responsibilities are split explicitly. The shared reducer defines the protocol and validates ordering. `ClineProvider` advances it at observable landmarks. The reducer never persists, never throws into the delegation flow, and never drives rollback. The checker explores the outcomes the reducer permits. + +Settlement publication is asynchronous and policy-gated outside the delegation method. It stays model-only. The model keeps profile identities as opaque names/IDs. It does not model API secrets, provider construction, VS Code transport latency, filesystem durability, scheduler fairness, or crash/restart consistency. + +A commit observed committed through reconciliation keeps the durable delegation and the running child in the model exactly as production does. Child history is modeled as absent in that case. An incoherent reconciliation appears as a labeled non-destructive degraded-abort. Recovery after a restart is not claimed here. The user-facing reopen flow and store-level guards handle that. + +Started projection queue ownership is represented minimally. A started write is never modeled as cancelled. The child may start and publication may settle while the projection is still unresolved. The bounded queue's admission/execution distinction is enforced by the production tests in `src/__tests__/ClineProvider.delegation.spec.ts`, not re-modeled here. + +That distinction is implemented once, centrally. A queued callback whose caller timed out, or whose provider is disposing, is rejected at admission before `fn` is called. Provider disposal aborts queued admissions, stops post-dispose marker/event updates, waits for started writes only to a bounded deadline, then detaches the queue with handled promises. Those bounds are production-test assertions, not model claims. ## Shared-store concurrency model -The same `pnpm lifecycle:model-check` command also runs a second bounded explorer over two `TaskHistoryStore` hosts. It imports the production `computeHistoryDelta` and `mergeHistoryDelta` functions, so its semantics match the store rather than assuming coherent caches or transactional pair writes: +The same `pnpm lifecycle:model-check` command runs a second bounded explorer over two `TaskHistoryStore` hosts. It imports the production `computeHistoryDelta` and `mergeHistoryDelta` functions. Its semantics match the store. It does not assume coherent caches or transactional pair writes: - each host has an independent cache and host-local mutex; - store read/update operations hold the host mutex, while live-task snapshots used by completion and message saves may outlive it; @@ -72,22 +143,30 @@ The same `pnpm lifecycle:model-check` command also runs a second bounded explore - successful pair-operation cache entries publish together after both file writes; if the second write fails, the cache publishes only the first committed record; - cache refresh is explicit and may occur after an external live-task snapshot was captured. -There is no production record version or compare-and-swap token today. The model therefore does not invent one. It universally checks host-mutex and file-lock ownership, whole-file delta rejection, disk-field preservation, `childIds` union, and pair write order. Six scenarios, including distinct-task writes from #920 and a second-write pair failure, and all seven phases (`read`, `prepare`, `revalidate`, `commit`, `refresh`, `reject`, and `fail`) must remain reachable without exceeding the state/depth budgets. Positive semantic landmarks additionally require a stale cache beside newer disk state, the first pair write committed while the second is pending, and the same committed prefix retained after the second write fails. +There is no production record version or compare-and-swap token. The model does not invent one. It checks host-mutex and file-lock ownership, whole-file delta rejection, disk-field preservation, `childIds` union, and pair write order. + +Six scenarios must remain reachable without exceeding the state/depth budgets. These include distinct-task writes from [#920](https://github.com/Zoo-Code-Org/Zoo-Code/issues/920) and a second-write pair failure. All seven phases must remain reachable: `read`, `prepare`, `revalidate`, `commit`, `refresh`, `reject`, and `fail`. Positive semantic landmarks also require a stale cache beside newer disk state, the first pair write committed while the second is pending, and the same committed prefix kept after the second write fails. + +`TaskHistoryStore.readFresh` is lock-aware. It takes the same per-file advisory `proper-lockfile` lock that `safeWriteJson` acquires for the same path, through the shared `withAdvisoryFileLock` helper. It runs behind the store's in-process write lock and follows the same lock order writers use. It therefore waits out an in-flight cross-host write. It can never observe the write's backup/commit rename gap as a transient `missing`. + +`readFresh` is also identity-strict. A parsed record whose own `id` differs from the requested task ID is `incompatible` and is never cached under the requested key. + +The bounded cross-host filesystem-lock behavior is not claimed by either model. It is proven by the production tests in `src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts` (gated advisory-lock writer, two-instance read-under-write) and the real-lockfile smoke test below. -Two desired properties are currently false and remain issue-keyed shortest-witness ratchets rather than silently allowed assertion failures: +Two desired properties are currently false. They are tracked as issue-keyed shortest-witness ratchets, not silently allowed assertion failures: -- [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): an old completion can commit after a newer handoff and clear it because disk revalidation checks status legality, not exact-child ownership. -- [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021): after abandonment and cache refresh, a stale live-task save can preserve the new interrupted status while restoring old lineage fields. +- [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): an old completion can commit after a newer handoff and clear it. Disk revalidation checks status legality, not exact-child ownership. +- [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021): after abandonment and cache refresh, a stale live-task save can keep the new interrupted status while restoring old lineage fields. -CI fails if either exact causal witness or violation class changes, a witness disappears without being promoted to a universal invariant, a named semantic landmark or modeled phase becomes unreachable, a new safety violation appears, or exploration truncates. Raw reachable-state totals are printed as diagnostics, not used as ratchets: harmless representation changes can alter them without weakening protocol coverage. +CI fails if the exact causal witness or violation class changes, a witness disappears without becoming a universal invariant, a named semantic landmark or modeled phase becomes unreachable, a new safety violation appears, or exploration truncates. Raw reachable-state totals are printed as diagnostics, not used as ratchets. Harmless representation changes can alter them without weakening protocol coverage. -The known-unsafe witnesses currently compare exact shortest action sequences. This is intentionally simple and reviewable, but brittle to harmless action renames or serialization refactors. A causal partial-order comparator would reduce that brittleness but would add a second trace-equivalence protocol to maintain. Until that complexity is justified, update an exact witness only after confirming the terminal violation class and required causal ordering are unchanged. +The known-unsafe witnesses compare exact shortest action sequences. This is simple and reviewable, but brittle to harmless action renames or serialization refactors. A causal partial-order comparator would reduce that brittleness. It would also add a second trace-equivalence protocol to maintain. Update an exact witness only after confirming the terminal violation class and required causal ordering are unchanged. -`TaskHistoryStore.realConcurrency.spec.ts` complements the abstract interleavings with one synchronized integration smoke check through the real `proper-lockfile` and filesystem rename path; broader VS Code E2E remains reserved for restart and extension-host behavior. +`TaskHistoryStore.realConcurrency.spec.ts` complements the abstract interleavings with one synchronized integration smoke check through the real `proper-lockfile` and filesystem rename path. Broader VS Code E2E remains reserved for restart and extension-host behavior. ## Task cleanup protocol model -The umbrella command also runs a separate bounded child model for in-memory abort, disposal, and provider-shutdown ordering. It models cleanup settlement and rejection as environment transitions and makes no filesystem, editor Promise, fairness, or timing-liveness claim. See [Task cleanup protocol model check](./task-cleanup-protocol-model.md). +The umbrella command also runs a separate bounded model for in-memory abort, disposal, and provider-shutdown ordering. The model treats cleanup settlement and rejection as environment transitions. It makes no filesystem, editor Promise, fairness, or timing-liveness claim. See [Task cleanup protocol model check](./task-cleanup-protocol-model.md). ## Completion persistence model @@ -129,7 +208,7 @@ These are safety claims within the documented bounds. The checks do not claim li ## Open-issue traceability -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. +The following table 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 | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -142,7 +221,7 @@ The following map separates issue observations from the architectural interpreta | [#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. +The issue-derived cases 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. ## Extending the model @@ -151,15 +230,15 @@ When production lifecycle behavior changes: 1. Define or update the pure transition in `taskLifecycle.ts`, then call it from the production operation. 2. Model the corresponding enabled event in `scripts/check-task-lifecycle.ts`. 3. Encode an invariant for the bug class, or a representative rejected-event scenario when the event intentionally leaves state unchanged. -4. Increase depth or task slots only when the new scenario requires it. Keep the state budget explicit and ensure CI completes quickly. -5. Convert any discovered counterexample into a focused production regression test as well as retaining the architectural invariant. +4. Increase depth or task slots only when the new scenario requires it. Keep the state budget explicit and make sure CI completes quickly. +5. Convert any discovered counterexample into a focused production regression test and keep the architectural invariant. -Completion-readiness changes belong in `scripts/check-completion-persistence.ts`; shared-store interleavings belong in `scripts/check-task-store-concurrency.ts`. Do not weaken bounds or remove an invariant merely to make CI pass. If state growth becomes difficult to control, split independent protocols or move the model to TLC/Quint with an implementation trace adapter rather than silently sampling the state space. +Provider-handoff changes belong in `scripts/check-provider-handoff.ts`. Completion-readiness changes belong in `scripts/check-completion-persistence.ts`. Shared-store interleavings belong in `scripts/check-task-store-concurrency.ts`. Do not weaken bounds or remove an invariant merely to make CI pass. If state growth becomes difficult to control, split independent protocols or move the model to TLC/Quint with an implementation trace adapter rather than silently sampling the state space. Parser request scoping is one such independent bounded submodel within the umbrella suite. Extend `scripts/check-native-tool-call-parser-scoping.ts` and its focused architecture document instead of adding parser state or transitions to `taskLifecycle.ts` or the persisted lifecycle state graph. ## Test layering -Keep reducer permutations in this model and focused Vitest suites. The real VS Code extension-host suite using a mocked provider in `apps/vscode-e2e/src/suite/subtasks.test.ts` already covers the boundaries the pure explorer cannot: task creation and rehydration, persisted parent-child state, cancellation during a delayed provider stream, interrupted-child resume, abandonment followed by a real resume/save/completion cycle, pending approvals across leave/return, and scheduler-driven resume. `restart-persistence.test.ts` separately verifies completion history through a fresh extension host. +Keep reducer permutations in this model and focused Vitest suites. The real VS Code extension-host suite uses a mocked provider in `apps/vscode-e2e/src/suite/subtasks.test.ts`. It covers boundaries the pure explorer cannot: task creation and rehydration, persisted parent-child state, cancellation during a delayed provider stream, interrupted-child resume, abandonment followed by a real resume/save/completion cycle, pending approvals across leave/return, and scheduler-driven resume. `restart-persistence.test.ts` separately verifies completion history through a fresh extension host. -Add E2E coverage only when a lifecycle change crosses one of those runtime boundaries or introduces a new one. For example, #1453 persistence-readiness semantics require a controlled fresh-host test, and #369/#372 fan-out requires scheduler permit, live-parent routing, orphan cleanup, and task-scoping E2E. Do not add E2E cases solely to replay reducer orderings already exhausted here; they increase fixture and timing cost without strengthening the proof claim. +Add E2E coverage only when a lifecycle change crosses one of those runtime boundaries or introduces a new one. For example, #1453 persistence-readiness semantics require a controlled fresh-host test. #369/#372 fan-out requires scheduler permit, live-parent routing, orphan cleanup, and task-scoping E2E. Do not add E2E cases solely to replay reducer orderings already exhausted here. Those increase fixture and timing cost without strengthening the proof claim. diff --git a/scripts/check-provider-handoff.ts b/scripts/check-provider-handoff.ts index 209f3491bb..12ecf642b0 100644 --- a/scripts/check-provider-handoff.ts +++ b/scripts/check-provider-handoff.ts @@ -2,51 +2,81 @@ import assert from "node:assert/strict" import type { HistoryItem } from "../packages/types/src/history" import { + applyProviderHandoffEvent, createProviderHandoffPlan, decideProviderHandoffProfile, + initialProviderHandoffState, + type ProviderHandoffEvent, + type ProviderHandoffProjectionBoundary, + type ProviderHandoffState, type ProviderProfileRef, } from "../src/core/task-persistence/providerHandoff" import { delegateTaskToChild } from "../src/core/task-persistence/taskLifecycle" +/** + * Bounded model of the provider handoff transaction protocol + * (`src/core/task-persistence/providerHandoff.ts`). The explorer drives the + * production reducer with nondeterministic coarse failure outcomes and checks + * safety invariants over every reachable state. Profile identities are opaque + * names/IDs; no API keys or provider secrets are modeled. + */ + type TaskId = "root" | "parent" | "child" type Topology = "sole-parent" | "exposed-root" -type ProfileScenario = "saved" | "unsaved" | "locked" -type Phase = - | "parent-open" - | "parent-removed" - | "profile-prepared" - | "child-created" - | "delegation-committed" - | "child-running" - | "settled" - -interface RuntimeTask { - mode: string - profile: string +type ProfilePath = "saved" | "unsaved" | "locked" +type CurrentTaskId = TaskId | undefined + +const { requestedMode } = createProviderHandoffPlan("child-mode") +const currentProfile: ProviderProfileRef = { name: "root-profile", id: "root-profile-id" } +const savedProfile: ProviderProfileRef = { name: "child-profile", id: "child-profile-id" } +const MAX_STATES = 600 +const MAX_DEPTH = 16 + +function handoffGeneration(path: ProfilePath): string { + return `handoff-generation-${path}` } +const FOREIGN_GENERATION = "foreign-generation" -interface ModelState { +interface Environment { topology: Topology - scenario: ProfileScenario - phase: Phase - currentTaskId?: TaskId - rootTask: RuntimeTask + profilePath: ProfilePath + currentTaskId: CurrentTaskId + rootTask: { mode: string; profile: string } rootHistory: HistoryItem parentHistory: HistoryItem - childTask?: RuntimeTask + childTask?: { mode: string; profile: string; generation: string } childStarted: boolean globalMode: string globalProfile: string modeProfileId?: string - publications: Array - refinementCommits: number + publications: Array<"empty" | "child"> + publishedMode?: string + publishedProfile?: string + /** Number of delegation writes that actually persisted (protocol + observations). */ + commitCount: number + /** + * Set when a legacy projection write has started. Mirrors production queue + * ownership: a started projection write is never modeled as cancellable — + * its outcome always settles into the protocol (synchronized/stale) — but + * the child start and publication do not wait for it. + */ + projectionWriteStarted: boolean + /** + * The child's durable history record is optional at the commit boundary. + * The model never creates one, so an observed-committed reconciliation is + * always "exact parent delegation without child history", matching + * production's TaskHistoryStore readFresh observation. + */ + childHistoryPresent: boolean + /** Set only by the legacy witness driver: a pre-commit mutating projection. */ + preCommitProjectionMutation: boolean + /** Set only by the legacy witness driver: a pending-state publication. */ + pendingPublication: boolean } -interface ModelPolicy { - target: "none" | "implicit-current" - mutateExposedTask: boolean - publishWhilePending: boolean - applyProviderSettingsToContext: boolean +interface ModelState { + protocol: ProviderHandoffState + env: Environment } interface TraceStep { @@ -54,37 +84,6 @@ interface TraceStep { state: ModelState } -interface ModelResult { - states: number - traces: number - actions: Set -} - -interface Counterexample { - violation: string - trace: TraceStep[] -} - -const requestedMode = "child-mode" -const currentProfile: ProviderProfileRef = { name: "root-profile", id: "root-profile-id" } -const savedProfile: ProviderProfileRef = { name: "child-profile", id: "child-profile-id" } -const MAX_STATES = 100 -const actionOrder = [ - "remove-parent", - "prepare-profile", - "create-child", - "persist-delegation", - "start-child", - "publish-child", -] as const - -const legacyPolicy: ModelPolicy = { - target: "implicit-current", - mutateExposedTask: true, - publishWhilePending: true, - applyProviderSettingsToContext: true, -} - function history(id: TaskId, parentTaskId?: TaskId): HistoryItem { return { id, @@ -102,13 +101,12 @@ function history(id: TaskId, parentTaskId?: TaskId): HistoryItem { } } -function initialState(topology: Topology, scenario: ProfileScenario): ModelState { +function initialEnvironment(topology: Topology, profilePath: ProfilePath): Environment { const parentHistory = history("parent", topology === "exposed-root" ? "root" : undefined) const rootHistory = topology === "exposed-root" ? delegateTaskToChild(history("root"), "parent") : history("root") return { topology, - scenario, - phase: "parent-open", + profilePath, currentTaskId: "parent", rootTask: { mode: "root-mode", profile: currentProfile.name }, rootHistory, @@ -117,242 +115,739 @@ function initialState(topology: Topology, scenario: ProfileScenario): ModelState globalMode: "root-mode", globalProfile: currentProfile.name, publications: [], - refinementCommits: 0, + commitCount: 0, + projectionWriteStarted: false, + childHistoryPresent: false, + preCommitProjectionMutation: false, + pendingPublication: false, + } +} + +function initialState(topology: Topology, profilePath: ProfilePath): ModelState { + return { + protocol: initialProviderHandoffState(), + env: initialEnvironment(topology, profilePath), } } -function profileDecision(state: ModelState) { +function profileDecision(env: Environment) { return decideProviderHandoffProfile({ - locked: state.scenario === "locked", + locked: env.profilePath === "locked", currentProfile, - savedProfile: state.scenario === "saved" ? savedProfile : undefined, + savedProfile: env.profilePath === "saved" ? savedProfile : undefined, }) } -function productionPolicy(): ModelPolicy { - const { policy } = createProviderHandoffPlan(requestedMode) - return { - target: policy.targetTask === null ? "none" : "implicit-current", - mutateExposedTask: policy.mutateExposedTask, - publishWhilePending: policy.publishWhilePending, - applyProviderSettingsToContext: policy.applyProviderSettingsToContext, - } +/** The profile the prepared context binds, resolved like production preparation. */ +function expectedProfile(env: Environment): string { + return profileDecision(env).profile?.name ?? currentProfile.name } -function nextAction(phase: Phase): (typeof actionOrder)[number] | undefined { - switch (phase) { - case "parent-open": - return "remove-parent" +/** Durable mode mapping intent, resolved like production preparation. */ +function expectedModeProfileId(env: Environment): string | undefined { + return env.profilePath === "saved" ? savedProfile.id : env.profilePath === "unsaved" ? currentProfile.id : undefined +} + +// --------------------------------------------------------------------------- +// Nondeterministic protocol events per phase +// --------------------------------------------------------------------------- + +interface Candidate { + name: string + event: ProviderHandoffEvent +} + +function candidateEvents(ms: ModelState): Candidate[] { + const { protocol: p, env } = ms + const generation = handoffGeneration(env.profilePath) + switch (p.phase) { + case "initial": + return [ + { name: "prepare", event: { type: "prepare", generation } }, + { name: "prepare-failed", event: { type: "prepare-failed" } }, + ] + case "prepared": + return [{ name: "remove-parent", event: { type: "remove-parent" } }] case "parent-removed": - return "prepare-profile" - case "profile-prepared": - return "create-child" + return [ + { name: "create-child", event: { type: "create-child", generation } }, + { name: "create-child-failed", event: { type: "create-child-failed" } }, + ] case "child-created": - return "persist-delegation" + return [ + { name: "commit-delegation", event: { type: "commit-delegation" } }, + { name: "commit-failed", event: { type: "commit-failed" } }, + ] case "delegation-committed": - return "start-child" - case "child-running": - return "publish-child" - case "settled": - return undefined + return [{ name: "activate-context", event: { type: "activate-context", generation } }] + case "context-active": { + // The child starts immediately after context activation and must + // never await the legacy projection; the projection itself is + // fire-and-forget background work that may settle before OR after + // the child started. Both orders (and a projection that never + // completes before publication) are protocol states. + const candidates: Candidate[] = [{ name: "start-child", event: { type: "start-child" } }] + if (p.projection === "original") { + candidates.push( + { + name: "project-legacy:ok", + event: { type: "project-legacy", boundary: "profile-store", ok: true }, + }, + { + name: "project-legacy:fail-profile-store", + event: { type: "project-legacy", boundary: "profile-store", ok: false }, + }, + { + name: "project-legacy:fail-context-proxy", + event: { type: "project-legacy", boundary: "context-proxy", ok: false }, + }, + ) + } + return candidates + } + case "child-running": { + // A projection still unresolved when the child started may settle + // while the child runs; publication is policy-gated and independent. + const candidates: Candidate[] = [{ name: "publish", event: { type: "publish" } }] + if (p.projection === "original") { + candidates.push( + { + name: "project-legacy:ok", + event: { type: "project-legacy", boundary: "profile-store", ok: true }, + }, + { + name: "project-legacy:fail-profile-store", + event: { type: "project-legacy", boundary: "profile-store", ok: false }, + }, + { + name: "project-legacy:fail-context-proxy", + event: { type: "project-legacy", boundary: "context-proxy", ok: false }, + }, + ) + } + return candidates + } + case "aborting": { + const candidates: Candidate[] = [] + if (p.failure?.boundary === "delegation-commit" && p.failure.commitDurability === "unresolved") { + // Production authoritatively reconciles the failed commit before + // any rollback: strictly re-read the parent from disk (child + // history is optional) and observe the outcome. The observation + // labels mirror the fresh-read classifications. + return [ + { + name: "observe-commit-durability:uncommitted", + event: { + type: "observe-commit-durability", + durability: "uncommitted", + observation: "unchanged", + }, + }, + { + name: "observe-commit-durability:committed", + event: { type: "observe-commit-durability", durability: "committed", observation: "exact" }, + }, + { + name: "observe-commit-durability:incoherent", + event: { + type: "observe-commit-durability", + durability: "incoherent", + observation: "other-child", + }, + }, + ] + } + // Each rollback step runs at most once, like the production rollback. + if (p.childPresence === "paused" && !p.rollbackFailures.includes("child-cleanup")) { + candidates.push( + { name: "rollback-cleanup:ok", event: { type: "rollback-cleanup", ok: true } }, + { name: "rollback-cleanup:failed", event: { type: "rollback-cleanup", ok: false } }, + ) + } + const cleanupHandled = p.childPresence === "absent" || p.rollbackFailures.includes("child-cleanup") + if (cleanupHandled && !p.rollbackFailures.includes("parent-restoration")) { + candidates.push( + { name: "rollback-restore:ok", event: { type: "rollback-restore", ok: true } }, + { name: "rollback-restore:failed", event: { type: "rollback-restore", ok: false } }, + ) + } + return candidates + } + default: + return [] } } -function transition(state: ModelState, action: (typeof actionOrder)[number], policy: ModelPolicy): ModelState { - const next = structuredClone(state) - const decision = profileDecision(state) - - switch (action) { +/** + * Environment effect of one protocol step. Mirrors production: preparation is + * read-only, projections happen strictly post-commit, the delegation record + * refines the production `delegateTaskToChild`, and a commit that is observed + * as durable leaves a delegated parent record on disk. + */ +function applyEnvironment(ms: ModelState, candidate: Candidate): ModelState { + const env = structuredClone(ms.env) + switch (candidate.event.type) { + case "prepare": + case "prepare-failed": + case "create-child-failed": + case "activate-context": + case "rollback-cleanup": + // Read-only steps and protocol-only bookkeeping: no observable change. + break case "remove-parent": - next.phase = "parent-removed" - next.currentTaskId = state.topology === "exposed-root" ? "root" : undefined - return next - case "prepare-profile": { - next.phase = "profile-prepared" - next.globalMode = requestedMode - if (policy.applyProviderSettingsToContext && decision.profile) { - next.globalProfile = decision.profile.name + env.currentTaskId = env.topology === "exposed-root" ? "root" : undefined + break + case "create-child": + env.currentTaskId = "child" + env.childTask = { + mode: requestedMode, + profile: expectedProfile(env), + generation: candidate.event.generation, } - if (decision.source === "unsaved-current") { - next.modeProfileId = decision.persistModeProfileId + break + case "commit-delegation": + env.parentHistory = delegateTaskToChild(env.parentHistory, "child") + env.commitCount += 1 + break + case "observe-commit-durability": + if (candidate.event.durability === "committed") { + // Exact parent delegation, no child history: production keeps + // the durable delegation and continues with the running child. + env.parentHistory = delegateTaskToChild(env.parentHistory, "child") + env.commitCount += 1 } - if (policy.target === "implicit-current" && policy.mutateExposedTask && next.currentTaskId === "root") { - next.rootTask = { mode: requestedMode, profile: next.globalProfile } - next.rootHistory = { ...next.rootHistory, mode: requestedMode } + break + case "project-legacy": + // The write starts before its outcome is known; it is never + // cancellable once started (bounded queue ownership lives in the + // production provider, enforced by tests, not by this protocol). + env.projectionWriteStarted = true + if (candidate.event.ok) { + env.globalMode = requestedMode + // Profile intent: locked handoffs preserve the pinned identity + // (no profile write); saved/unsaved set the prepared identity. + if (env.profilePath !== "locked") { + env.globalProfile = expectedProfile(env) + } + env.modeProfileId = expectedModeProfileId(env) } - if (policy.publishWhilePending) next.publications.push(next.currentTaskId) - return next - } - case "create-child": - next.phase = "child-created" - next.currentTaskId = "child" - next.childTask = { mode: next.globalMode, profile: next.globalProfile } - return next - case "persist-delegation": - next.phase = "delegation-committed" - next.parentHistory = delegateTaskToChild(next.parentHistory, "child") - next.refinementCommits++ - return next + break case "start-child": - next.phase = "child-running" - next.childStarted = true - return next - case "publish-child": - next.phase = "settled" - next.publications.push(next.currentTaskId) - return next + env.childStarted = true + break + case "publish": { + const stale = ms.protocol.projection === "stale" + env.publications = ["child"] + env.publishedMode = stale ? (env.childTask?.mode ?? requestedMode) : env.globalMode + env.publishedProfile = stale ? (env.childTask?.profile ?? expectedProfile(env)) : env.globalProfile + break + } + case "rollback-restore": + if (candidate.event.ok) env.currentTaskId = "parent" + break } + return { protocol: ms.protocol, env } } -function phaseAtLeast(state: ModelState, phase: Phase): boolean { - const phases: Phase[] = [ - "parent-open", - "parent-removed", - "profile-prepared", - "child-created", - "delegation-committed", - "child-running", - "settled", - ] - return phases.indexOf(state.phase) >= phases.indexOf(phase) -} +// --------------------------------------------------------------------------- +// Invariants +// --------------------------------------------------------------------------- -function violations(state: ModelState): string[] { - const result: string[] = [] - const initialRoot = initialState(state.topology, state.scenario) - const decision = profileDecision(state) - const expectedProfile = decision.profile?.name ?? currentProfile.name +function violations(ms: ModelState): string[] { + const { protocol: p, env: e } = ms + const found: string[] = [] + const initial = initialEnvironment(e.topology, e.profilePath) + const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b) - if (state.publications.some((taskId) => taskId === undefined)) { - result.push("published an empty task while child handoff was pending") + // No unrelated-root mutation (both topologies; the sole parent has no root). + if (!sameJson(e.rootTask, initial.rootTask) || !sameJson(e.rootHistory, initial.rootHistory)) { + found.push("mutated the unrelated exposed root task") } - if (state.phase !== "settled" && state.publications.length > 0) { - result.push("published state before child handoff settled") + // No global/profile projection mutation before the delegation is committed. + if (p.delegation === "none") { + if ( + e.globalMode !== initial.globalMode || + e.globalProfile !== initial.globalProfile || + e.modeProfileId !== initial.modeProfileId + ) { + found.push("mutated the global/profile projection before the delegation commit") + } + } + if (e.preCommitProjectionMutation) { + found.push("mutated the global/profile projection before the delegation commit") + } + if (e.pendingPublication) { + found.push("published an empty task while child handoff was pending") + } + + // Exactly one lifecycle commit, and the environment agrees with the + // protocol on which commits are durable. After a failed commit attempt the + // protocol records the attempt while the environment records only writes + // that actually persisted (resolved by the durability observation). + if (p.delegation === "committed" && e.commitCount !== 1) { + found.push("a committed delegation without exactly one persisted commit") + } + if (p.delegation === "none" && e.commitCount !== 0) { + found.push("a persisted delegation commit that the protocol does not record as committed") + } + if (e.commitCount > 1) { + found.push("performed more than one delegation commit") + } + if (p.commitAttempts > 1) { + found.push("attempted more than one delegation commit") + } + + // One prepared generation binds child creation, delegation, and authority. + if (e.childTask && p.generation !== undefined && e.childTask.generation !== p.generation) { + found.push("used a generation other than the one prepared context") + } + + // Start/publication require durable commit plus context activation. + if (e.childStarted && (p.delegation !== "committed" || p.contextAuthority !== "child")) { + found.push("started the child before the delegation was durable and the context authority moved") } if ( - JSON.stringify(state.rootTask) !== JSON.stringify(initialRoot.rootTask) || - JSON.stringify(state.rootHistory) !== JSON.stringify(initialRoot.rootHistory) + e.publications.length > 0 && + (!e.childStarted || p.delegation !== "committed" || p.contextAuthority !== "child") ) { - result.push("mutated the unrelated exposed root task") + found.push("published child state before durable commit, context activation, and start") } - if (phaseAtLeast(state, "profile-prepared") && state.globalProfile !== expectedProfile) { - result.push("prepared the wrong child profile") + // No empty or intermediate publication. + if (e.publications.some((publication) => publication !== "child")) { + found.push("published an intermediate or empty state instead of the settled child") } - if (state.scenario === "unsaved" && phaseAtLeast(state, "profile-prepared")) { - if (state.modeProfileId !== currentProfile.id) result.push("did not persist the inherited unsaved profile") + + // A committed delegation is exactly the production refinement of the parent record. + const refinedParent = delegateTaskToChild(initial.parentHistory, "child") + if (p.delegation === "committed" && !sameJson(e.parentHistory, refinedParent)) { + found.push("a committed delegation was not reflected in the refined parent record") } - if (state.scenario !== "unsaved" && state.modeProfileId !== undefined) { - result.push("persisted an unexpected mode profile") + if (!sameJson(e.parentHistory, initial.parentHistory) && !sameJson(e.parentHistory, refinedParent)) { + found.push("parent history diverged from the original and the refined delegation") } - if (phaseAtLeast(state, "child-created")) { - if (state.childTask?.mode !== requestedMode || state.childTask.profile !== expectedProfile) { - result.push("created the child with the wrong mode or profile") + + // Stale projection cannot alter child authority or published values. + if (p.projection === "stale") { + if (p.contextAuthority !== "child") { + found.push("a stale projection changed the context authority") + } + if ( + e.publications.length > 0 && + (e.publishedMode !== e.childTask?.mode || e.publishedProfile !== e.childTask?.profile) + ) { + found.push("publication used stale global values instead of the child's prepared context") } } - if (state.childStarted && state.refinementCommits !== 1) { - result.push("started the child before the atomic delegation commit") + + // Clean abort: parent/current restored, no child, delegation, publication, + // or projection residue, and the original parent record back. + if (p.phase === "aborted") { + if (p.parentPresence !== "current" && p.parentPresence !== "restored") { + found.push("clean abort left the parent neither current nor restored") + } + if (e.currentTaskId !== "parent") { + found.push("clean abort did not leave the parent as the current task") + } + if ( + p.childPresence !== "absent" || + p.delegation !== "none" || + p.publication !== "none" || + p.projection !== "original" + ) { + found.push("clean abort left child, delegation, publication, or projection residue") + } + if (!sameJson(e.parentHistory, initial.parentHistory)) { + found.push("clean abort did not restore the original parent record") + } } - if (phaseAtLeast(state, "delegation-committed")) { - const expectedParent = delegateTaskToChild(initialRoot.parentHistory, "child") - if (JSON.stringify(state.parentHistory) !== JSON.stringify(expectedParent)) { - result.push("delegation commit did not refine delegateTaskToChild") + + // Degraded abort stays visible with its original failure and rollback labels. + if (p.phase === "degraded-abort") { + if (!p.failure) found.push("degraded abort lost its primary failure boundary") + if (p.failure?.commitDurability === "committed" && p.delegation !== "committed") { + found.push("degraded abort hid a durable delegation") + } + // An incoherent reconciliation is non-destructive: the paused child and + // the parent record are left exactly as they were, publication never + // runs, and the ambiguity stays visible for operators. + if (p.failure?.commitDurability === "incoherent") { + if (p.childPresence !== "paused" || p.parentPresence !== "removed") { + found.push("an incoherent commit reconciliation mutated the paused child or the parent record") + } + if (p.publication !== "none" || e.publications.length > 0) { + found.push("published after an incoherent commit reconciliation") + } + if (p.rollbackFailures.length > 0) { + found.push("an incoherent commit reconciliation ran rollback steps") + } } - if (state.refinementCommits !== 1) result.push("atomic delegation commit count was not exactly one") } - if (state.phase === "settled" && state.publications.at(-1) !== "child") { - result.push("final publication did not identify the child") + + // Settlement contract: one committed, activated, running child publication + // that refines the production delegation transition. + if (p.phase === "settled") { + if ( + !( + p.delegation === "committed" && + p.contextAuthority === "child" && + p.childPresence === "running" && + p.publication === "child" + ) + ) { + found.push("settled without a committed, activated, running child publication") + } + if (!sameJson(e.parentHistory, refinedParent)) { + found.push("settlement did not refine delegateTaskToChild") + } + if (p.projection === "synchronized") { + if (e.globalMode !== requestedMode) { + found.push("synchronized projection did not match the prepared context") + } + // Profile intent: locked paths preserve the pinned identity, so the + // global profile must still equal the root's original identity. + const expectedGlobalProfile = e.profilePath === "locked" ? initial.globalProfile : expectedProfile(e) + if (e.globalProfile !== expectedGlobalProfile) { + found.push("synchronized projection did not match the prepared profile intent") + } + if (e.modeProfileId !== expectedModeProfileId(e)) { + found.push("synchronized projection did not persist the resolved mode mapping intent") + } + } + // Queue ownership: once a projection write has started it is never + // modeled as cancelled — the protocol records its settled outcome + // (synchronized or stale) in the same step, so a settled state whose + // projection is still `original` proves publication legitimately raced + // ahead of a projection that had not yet started (bounded model checks + // publish-before-project as a legal background-work interleaving). + if (p.projection === "original" && e.projectionWriteStarted) { + found.push("a started projection write vanished without settling") + } + // A started-but-stale projection never overwrites a newer generation's + // publication: stale publication derives from the child's prepared + // context regardless of the projection write outcome. + if (e.projectionWriteStarted && p.projection === "stale" && e.publications.length > 0) { + if (e.publishedMode !== e.childTask?.mode || e.publishedProfile !== e.childTask?.profile) { + found.push("a settled stale projection published values that are not the child's") + } + } } - return result + + return found +} + +// --------------------------------------------------------------------------- +// Landmarks, rejection coverage, and applied-action coverage +// --------------------------------------------------------------------------- + +function landmarksOf(ms: ModelState): string[] { + const { protocol: p, env: e } = ms + const marks: string[] = [] + if (p.phase === "settled") { + marks.push(`settlement:${e.profilePath}`) + marks.push(`settlement:${e.topology}`) + marks.push("settlement:success") + if (p.projection === "stale" && p.projectionFailure === "profile-store") { + marks.push("projection:stale-profile-store") + } + if (p.projection === "stale" && p.projectionFailure === "context-proxy") { + marks.push("projection:stale-context-proxy") + } + } + if (p.phase === "aborted") { + marks.push("abort:clean") + if (p.failure?.boundary === "preparation") marks.push("abort:preparation-failure") + if (p.failure?.boundary === "child-creation") marks.push("abort:child-creation-failure") + } + if (p.phase === "degraded-abort") { + marks.push("abort:degraded") + if (p.rollbackFailures.includes("child-cleanup")) marks.push("rollback:cleanup-failure") + if (p.rollbackFailures.includes("parent-restoration")) marks.push("rollback:restoration-failure") + } + if (p.failure?.boundary === "delegation-commit" && p.failure.commitDurability === "uncommitted") { + marks.push("commit-ambiguity:observed-uncommitted") + } + if (p.failure?.boundary === "delegation-commit" && p.failure.commitDurability === "committed") { + marks.push("commit-ambiguity:observed-committed") + // Production reconciliation keeps the child running: an observed + // committed delegation settles instead of degrading. + if (p.phase === "settled") marks.push("commit-ambiguity:observed-committed-settled") + } + if (p.phase === "degraded-abort" && p.failure?.commitDurability === "incoherent") { + marks.push("commit-ambiguity:incoherent-degraded") + } + if (e.childStarted && p.projection === "original" && (p.phase === "child-running" || p.phase === "settled")) { + // The child started while the legacy projection was still unresolved. + marks.push("start:projection-unresolved") + } + if (p.phase === "settled" && e.projectionWriteStarted && e.profilePath === "locked") { + // A locked handoff settled without ever rewriting the pinned identity. + marks.push("projection:preserve-pinned-identity") + } + if (p.phase === "settled" && p.projection === "original" && e.childStarted) { + // Publication may settle before the background projection completes; + // the published values are derived from the child, not from global. + if (e.publications.length > 0) marks.push("settlement:projection-still-original") + } + return marks +} + +const REQUIRED_LANDMARKS = [ + "settlement:saved", + "settlement:unsaved", + "settlement:locked", + "settlement:sole-parent", + "settlement:exposed-root", + "settlement:success", + "projection:stale-profile-store", + "projection:stale-context-proxy", + "abort:clean", + "abort:preparation-failure", + "abort:child-creation-failure", + "abort:degraded", + "rollback:cleanup-failure", + "rollback:restoration-failure", + "commit-ambiguity:observed-uncommitted", + "commit-ambiguity:observed-committed", + "commit-ambiguity:observed-committed-settled", + "commit-ambiguity:incoherent-degraded", + "start:projection-unresolved", + "projection:preserve-pinned-identity", + "settlement:projection-still-original", +] as const + +/** Probes attempted on every state to prove illegal orderings are rejected. */ +function probeEvents(profilePath: ProfilePath): Array<{ name: string; event: ProviderHandoffEvent }> { + const generation = handoffGeneration(profilePath) + return [ + { name: "prepare", event: { type: "prepare", generation } }, + { name: "prepare-failed", event: { type: "prepare-failed" } }, + { name: "remove-parent", event: { type: "remove-parent" } }, + { name: "create-child", event: { type: "create-child", generation } }, + { name: "create-child:mismatch", event: { type: "create-child", generation: FOREIGN_GENERATION } }, + { name: "create-child-failed", event: { type: "create-child-failed" } }, + { name: "commit-delegation", event: { type: "commit-delegation" } }, + { name: "commit-failed", event: { type: "commit-failed" } }, + { + name: "observe-commit-durability", + event: { type: "observe-commit-durability", durability: "uncommitted" }, + }, + { name: "activate-context", event: { type: "activate-context", generation } }, + { name: "activate-context:mismatch", event: { type: "activate-context", generation: FOREIGN_GENERATION } }, + { name: "project-legacy", event: { type: "project-legacy", boundary: "profile-store", ok: true } }, + { name: "start-child", event: { type: "start-child" } }, + { name: "publish", event: { type: "publish" } }, + { name: "rollback-cleanup", event: { type: "rollback-cleanup", ok: true } }, + { name: "rollback-restore", event: { type: "rollback-restore", ok: true } }, + ] +} + +const REQUIRED_REJECTIONS = [ + "initial:remove-parent", // remove before prepare + "prepared:create-child", // create before remove + "parent-removed:commit-delegation", // commit before child + "child-created:activate-context", // context authority before commit + "child-created:start-child", // start before durable commit + activation + "child-created:publish", // publish before durable commit + activation + "delegation-committed:start-child", // start before activation + "delegation-committed:publish", + "delegation-committed:rollback-restore", // rollback after a committed delegation + "delegation-committed:commit-delegation", // exactly one lifecycle commit + "delegation-committed:observe-commit-durability", // observation requires a failed commit + "aborting:commit-delegation", // commit during abort + "aborting:rollback-cleanup", // reconciliation precedes any destructive rollback + "aborting:rollback-restore", + "context-active:publish", // publish before start + "context-active:project-legacy", // projection runs at most once + "settled:commit-delegation", // terminal state + "aborted:start-child", // terminal state + "parent-removed:create-child:mismatch", // generation binding + "delegation-committed:activate-context:mismatch", // generation binding +] as const + +const REQUIRED_APPLIED_ACTIONS = [ + "prepare", + "prepare-failed", + "remove-parent", + "create-child", + "create-child-failed", + "commit-delegation", + "commit-failed", + "observe-commit-durability:uncommitted", + "observe-commit-durability:committed", + "observe-commit-durability:incoherent", + "activate-context", + "project-legacy:ok", + "project-legacy:fail-profile-store", + "project-legacy:fail-context-proxy", + "start-child", + "publish", + "rollback-cleanup:ok", + "rollback-cleanup:failed", + "rollback-restore:ok", + "rollback-restore:failed", +] as const + +// --------------------------------------------------------------------------- +// Breadth-first exploration +// --------------------------------------------------------------------------- + +interface ModelResult { + states: number + terminals: number + landmarks: Set + rejections: Set + appliedActions: Set } -function canonical(state: ModelState): string { - return JSON.stringify(state) +function canonical(ms: ModelState): string { + return JSON.stringify(ms) } -function runModel(policy: ModelPolicy): ModelResult { - const queue: Array<{ state: ModelState; trace: TraceStep[] }> = [] +function runModel(): ModelResult { + const queue: Array<{ ms: ModelState; depth: number }> = [] + const visited = new Set() for (const topology of ["sole-parent", "exposed-root"] as const) { - for (const scenario of ["saved", "unsaved", "locked"] as const) { - const state = initialState(topology, scenario) - queue.push({ state, trace: [{ action: "initial", state }] }) + for (const profilePath of ["saved", "unsaved", "locked"] as const) { + const ms = initialState(topology, profilePath) + visited.add(canonical(ms)) + queue.push({ ms, depth: 0 }) } } - const visited = new Set(queue.map(({ state }) => canonical(state))) - const actions = new Set() - let settledTraces = 0 + const landmarks = new Set() + const rejections = new Set() + const appliedActions = new Set() + let terminals = 0 + for (let index = 0; index < queue.length; index++) { const node = queue[index]! - const found = violations(node.state) - if (found.length) throw new Error(`${found.join("; ")}\n${formatTrace(node.trace)}`) - const action = nextAction(node.state.phase) - if (!action) { - settledTraces++ - continue - } - actions.add(action) - const next = transition(node.state, action, policy) - const key = canonical(next) - if (!visited.has(key)) { - visited.add(key) - if (visited.size > MAX_STATES) { - throw new Error(`Provider handoff exploration exceeded its ${MAX_STATES}-state budget`) + const { ms } = node + const found = violations(ms) + if (found.length) throw new Error(`${found.join("; ")}\n${formatTrace(ms)}`) + + for (const mark of landmarksOf(ms)) landmarks.add(mark) + const candidateList = candidateEvents(ms) + if (candidateList.length === 0) terminals += 1 + + for (const candidate of candidateList) { + const transition = applyProviderHandoffEvent(ms.protocol, candidate.event) + if (!transition.ok) { + throw new Error( + `checker bug: candidate ${candidate.name} was rejected in phase ${ms.protocol.phase}: ${transition.reason}`, + ) } - queue.push({ state: next, trace: [...node.trace, { action, state: next }] }) + appliedActions.add(candidate.name) + const next = applyEnvironment({ protocol: ms.protocol, env: ms.env }, candidate) + const nextState: ModelState = { protocol: transition.state, env: next.env } + const key = canonical(nextState) + if (!visited.has(key)) { + visited.add(key) + if (visited.size > MAX_STATES) { + throw new Error(`Provider handoff exploration exceeded its ${MAX_STATES}-state budget`) + } + if (node.depth + 1 > MAX_DEPTH) { + throw new Error(`Provider handoff exploration exceeded its ${MAX_DEPTH}-event depth budget`) + } + queue.push({ ms: nextState, depth: node.depth + 1 }) + } + } + + // Rejection probes: illegal orderings must be rejected everywhere. + for (const probe of probeEvents(ms.env.profilePath)) { + const transition = applyProviderHandoffEvent(ms.protocol, probe.event) + if (!transition.ok) rejections.add(`${ms.protocol.phase}:${probe.name}`) } } - return { states: visited.size, traces: settledTraces, actions } + + return { states: visited.size, terminals, landmarks, rejections, appliedActions } } -function findCounterexample( - policy: ModelPolicy, - topology: Topology, - violationText: string, -): Counterexample | undefined { - let state = initialState(topology, "saved") - const trace: TraceStep[] = [{ action: "initial", state }] - while (true) { - const found = violations(state).find((violation) => violation === violationText) - if (found) return { violation: found, trace } - const action = nextAction(state.phase) - if (!action) return undefined - state = transition(state, action, policy) - trace.push({ action, state }) +// --------------------------------------------------------------------------- +// Legacy counterexample witnesses (pre-Phase-1 remove-then-prepare flow) +// --------------------------------------------------------------------------- + +interface Witness { + violation: string + trace: string[] +} + +function formatTrace(ms: ModelState): string { + return [ + `phase=${ms.protocol.phase}`, + `current=${ms.env.currentTaskId ?? "none"}`, + `globalMode=${ms.env.globalMode}`, + `globalProfile=${ms.env.globalProfile}`, + `publications=${JSON.stringify(ms.env.publications)}`, + ].join(", ") +} + +/** + * Deterministic legacy drive reproducing the pre-Phase-1 unsafe flow: remove + * the parent first, then run the mutating implicit-current profile switch that + * wrote global state and published while the handoff was still pending. Both + * witnesses must remain detectable as regression ratchets. + */ +function legacyWitness(topology: Topology, expectViolation: string): Witness { + const ms = initialState(topology, "saved") + const trace: string[] = ["initial"] + + // Legacy step 1: remove the parent before any preparation (now illegal). + ms.env.currentTaskId = topology === "exposed-root" ? "root" : undefined + trace.push("remove-parent") + + // Legacy step 2: mutating profile switch targeting the implicit current task. + ms.env.preCommitProjectionMutation = true + ms.env.globalMode = requestedMode + ms.env.globalProfile = savedProfile.name + if (topology === "exposed-root") { + ms.env.rootTask = { mode: requestedMode, profile: savedProfile.name } + ms.env.rootHistory = { ...ms.env.rootHistory, mode: requestedMode } + } else { + ms.env.pendingPublication = true + ms.env.publications.push("empty") } + trace.push("legacy-project") + + const found = violations(ms) + const violation = found.find((candidate) => candidate === expectViolation) + assert( + violation, + `legacy witness for "${expectViolation}" (${topology}) not detected; got: ${JSON.stringify(found)}`, + ) + return { violation, trace } } -function formatTrace(trace: TraceStep[]): string { - return trace - .map( - ({ action, state }) => - `${action}: phase=${state.phase}, current=${state.currentTaskId ?? "none"}, rootMode=${state.rootTask.mode}, globalProfile=${state.globalProfile}, publications=${JSON.stringify(state.publications)}`, - ) - .join(" -> ") +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const result = runModel() + +for (const required of REQUIRED_LANDMARKS) { + assert(result.landmarks.has(required), `semantic landmark became unreachable: ${required}`) +} +for (const required of REQUIRED_REJECTIONS) { + assert(result.rejections.has(required), `illegal-ordering rejection not exercised: ${required}`) +} +for (const required of REQUIRED_APPLIED_ACTIONS) { + assert(result.appliedActions.has(required), `modeled action became unreachable: ${required}`) } -const result = runModel(productionPolicy()) -assert.deepEqual([...result.actions], actionOrder) -assert.equal(result.traces, 6) +const emptyPublication = legacyWitness("sole-parent", "published an empty task while child handoff was pending") +const rootMutation = legacyWitness("exposed-root", "mutated the unrelated exposed root task") +assert.deepEqual(emptyPublication.trace, ["initial", "remove-parent", "legacy-project"]) +assert.deepEqual(rootMutation.trace, ["initial", "remove-parent", "legacy-project"]) -const emptyPublication = findCounterexample( - legacyPolicy, - "sole-parent", - "published an empty task while child handoff was pending", -) -const rootMutation = findCounterexample(legacyPolicy, "exposed-root", "mutated the unrelated exposed root task") -assert(emptyPublication) -assert(rootMutation) -assert.deepEqual( - emptyPublication.trace.map(({ action }) => action), - ["initial", "remove-parent", "prepare-profile"], +console.log( + `Provider handoff model check passed: ${result.states} reachable states, ${result.terminals} terminal states, ` + + `${result.appliedActions.size}/${REQUIRED_APPLIED_ACTIONS.length} actions reachable, ` + + `${result.landmarks.size} landmarks (all ${REQUIRED_LANDMARKS.length} required present), ` + + `${result.rejections.size} exercised illegal-ordering rejections, ` + + `3/3 profile paths, 2/2 topologies, 2/2 legacy counterexamples reproduced`, ) -assert.deepEqual( - rootMutation.trace.map(({ action }) => action), - ["initial", "remove-parent", "prepare-profile"], +console.log( + `Legacy empty-publication counterexample: ${emptyPublication.trace.join(" -> ")} (${emptyPublication.violation})`, ) - console.log( - `Provider handoff model check passed: ${result.states} reachable states, ${result.traces} scenario traces, ${result.actions.size}/${actionOrder.length} actions reachable, 3/3 profile paths, 2/2 legacy counterexamples reproduced`, + `Legacy exposed-root mutation counterexample: ${rootMutation.trace.join(" -> ")} (${rootMutation.violation})`, ) -console.log(`Legacy empty-publication counterexample: ${formatTrace(emptyPublication.trace)}`) -console.log(`Legacy exposed-root mutation counterexample: ${formatTrace(rootMutation.trace)}`) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 087ec3f919..78a699520e 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -1,11 +1,16 @@ // npx vitest run __tests__/ClineProvider.delegation.spec.ts import { describe, it, expect, vi } from "vitest" -import type { HistoryItem } from "@roo-code/types" +import type { HistoryItem, ProviderSettings } from "@roo-code/types" import { RooCodeEventName } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskScheduler } from "../core/task/TaskScheduler" -import { createProviderHandoffPlan } from "../core/task-persistence/providerHandoff" +import { + createPreparedProviderHandoffContext, + type PreparedProviderHandoffContext, + type ProviderHandoffProjectionOutcome, +} from "../core/task-persistence/providerHandoff" const parentHistoryItem: HistoryItem = { id: "parent-1", @@ -18,14 +23,29 @@ const parentHistoryItem: HistoryItem = { /** Minimal taskHistoryStore stub whose atomicReadAndUpdate calls the updater with the parent item. */ function makeStoreStub( - overrides: Partial<{ atomicReadAndUpdate: ReturnType; get: ReturnType }> = {}, + overrides: Partial<{ + atomicReadAndUpdate: ReturnType + get: ReturnType + readFresh: ReturnType + invalidate: ReturnType + }> = {}, ) { return { atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { updater(parentHistoryItem) return [] }), - get: vi.fn().mockReturnValue(undefined), + // A persisted parent record with no delegation, read strictly from its + // durable task file: the commit-rejection reconciliation reads this as + // an exact nondelegated preimage — definitively uncommitted. The child + // history is optional at this boundary and absent by default. + get: vi.fn((taskId: string) => (taskId === "parent-1" ? { ...parentHistoryItem } : undefined)), + readFresh: vi.fn(async (taskId: string) => + taskId === "parent-1" + ? { kind: "found" as const, item: { ...parentHistoryItem } } + : { kind: "missing" as const }, + ), + invalidate: vi.fn().mockResolvedValue(undefined), ...overrides, } } @@ -43,12 +63,102 @@ const makeParentTask = () => retrySaveApiConversationHistory: vi.fn(), }) as any +const SENTINEL_API_KEY = "sk-handoff-sentinel-123456" + +/** Prepared handoff snapshot double used by happy-path delegation tests. */ +const makePreparedHandoff = ( + overrides: Partial<{ + profileName: string | undefined + profileId: string | undefined + apiConfiguration: ProviderSettings + persistModeProfileId: string | undefined + }> = {}, +): PreparedProviderHandoffContext => + createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { + source: "unsaved-current", + name: overrides.profileName === undefined ? "profile-1" : overrides.profileName, + id: overrides.profileId === undefined ? "profile-1-id" : overrides.profileId, + }, + apiConfiguration: overrides.apiConfiguration ?? { apiProvider: providerIdentifiers.openrouter }, + persistModeProfileId: overrides.persistModeProfileId ?? "profile-1-id", + }) + +/** Child task double with the execution-context adoption hook the provider calls after the commit. */ +const makeChildTask = (taskId: string) => { + const run = vi.fn().mockResolvedValue(undefined) + return { + taskId, + start: vi.fn(), + run, + adoptHandoffExecutionContext: vi.fn(), + updateApiConfiguration: vi.fn(), + } +} + +/** Minimal prepared-handoff provider stub: preparation is stubbed, the post-commit projection runs harmlessly. */ +const makePreparationStub = (prepared: PreparedProviderHandoffContext) => vi.fn().mockResolvedValue(prepared) + +/** + * Real handoff prototype methods so `delegateParentAndOpenChild` can run with + * a stub `this`. Specific tests override individual entries (e.g. the + * preparation stub above). + */ +const handoffPrototype = { + prepareProviderHandoffContext: ClineProvider.prototype["prepareProviderHandoffContext"], + projectPreparedProviderHandoffState: ClineProvider.prototype["projectPreparedProviderHandoffState"], + runProviderHandoffProjectionWrites: ClineProvider.prototype["runProviderHandoffProjectionWrites"], + rollbackFailedDelegation: ClineProvider.prototype["rollbackFailedDelegation"], + restoreParentAfterFailedChildCreation: ClineProvider.prototype["restoreParentAfterFailedChildCreation"], + reconcileDelegationCommitFailure: ClineProvider.prototype["reconcileDelegationCommitFailure"], + delegateParentAndOpenChildUnlocked: ClineProvider.prototype["delegateParentAndOpenChildUnlocked"], + runDelegationTransition: ClineProvider.prototype["runDelegationTransition"], + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + markStaleProviderHandoffProjection: ClineProvider.prototype["markStaleProviderHandoffProjection"], + clearStaleProviderHandoffProjection: ClineProvider.prototype["clearStaleProviderHandoffProjection"], + supersedeStaleProviderHandoffProjection: ClineProvider.prototype["supersedeStaleProviderHandoffProjection"], + isCurrentProfileMutationGeneration: ClineProvider.prototype["isCurrentProfileMutationGeneration"], + isProviderHandoffProjectionStillRelevant: ClineProvider.prototype["isProviderHandoffProjectionStillRelevant"], + invalidateProviderHandoffProjectionState: ClineProvider.prototype["invalidateProviderHandoffProjectionState"], + registerProviderHandoffProjectionTarget: ClineProvider.prototype["registerProviderHandoffProjectionTarget"], + admitProviderHandoffProjectionTarget: ClineProvider.prototype["admitProviderHandoffProjectionTarget"], + isExplicitProfileClearInForce: ClineProvider.prototype["isExplicitProfileClearInForce"], + deleteTaskFromState: ClineProvider.prototype.deleteTaskFromState, + markDelegatedChildInterrupted: ClineProvider.prototype["markDelegatedChildInterrupted"], + markDelegatedChildInterruptedUnlocked: ClineProvider.prototype["markDelegatedChildInterruptedUnlocked"], + evictCurrentTask: ClineProvider.prototype.evictCurrentTask, +} + +const makeProviderStub = (partial: Record): ClineProvider => + ({ + // Per-parent delegation transition serialization and the bounded + // profile-mutation queue, shared by every test through the real + // prototype implementations. + delegationTransitionLocks: new Map>(), + delegationTransitionOwners: new Map(), + cancelledDelegationChildIds: new Set(), + explicitProfileClearChildIds: new Set(), + providerProfileMutationQueue: Promise.resolve(), + providerProfileMutationReservation: 0, + providerProfileMutationGeneration: 0, + providerProfileMutationSettledGeneration: 0, + profileMutationAbortControllers: new Set(), + nextProviderHandoffProjectionToken: 0, + _disposed: false, + // No current task by default: the durable explicit-clear fallback + // only consults the manager for a still-current task. + getCurrentTask: vi.fn(() => undefined), + ...handoffPrototype, + ...partial, + }) as unknown as ClineProvider + describe("ClineProvider.delegateParentAndOpenChild()", () => { it("rejects a stale restored action before delegation side effects", async () => { const parentTask = makeParentTask() const removeClineFromStack = vi.fn() const createTask = vi.fn() - const handleModeSwitch = vi.fn() + const prepareProviderHandoffContext = vi.fn() const taskHistoryStore = makeStoreStub({ get: vi.fn().mockReturnValue({ ...parentHistoryItem, @@ -62,13 +172,13 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }, }), }) - const provider = { + const provider = makeProviderStub({ getCurrentTask: vi.fn(() => parentTask), removeClineFromStack, createTask, - handleModeSwitch, + prepareProviderHandoffContext, taskHistoryStore, - } as unknown as ClineProvider + }) await expect( ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { @@ -82,7 +192,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(parentTask.flushPendingToolResultsToHistory).not.toHaveBeenCalled() expect(removeClineFromStack).not.toHaveBeenCalled() - expect(handleModeSwitch).not.toHaveBeenCalled() + expect(prepareProviderHandoffContext).not.toHaveBeenCalled() expect(createTask).not.toHaveBeenCalled() expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled() }) @@ -105,18 +215,18 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), } const parentTask = makeParentTask() - const child = { taskId: "child-1", run: vi.fn().mockResolvedValue(undefined) } - const provider = { + const child = makeChildTask("child-1") + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => parentTask), removeClineFromStack: vi.fn().mockResolvedValue(undefined), createTask: vi.fn().mockResolvedValue(child), - handleModeSwitch: vi.fn().mockResolvedValue(undefined), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), log: vi.fn(), isViewLaunched: false, taskHistoryStore, - } as unknown as ClineProvider + }) await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { parentTaskId: "parent-1", @@ -130,26 +240,26 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(current).toMatchObject({ status: "delegated", awaitingChildId: "child-1" }) }) - it("fails closed when handleModeSwitch rejects: parent stays current and no child is created", async () => { + it("fails closed when preparation rejects: parent stays current, no child, no store writes", async () => { const parentTask = makeParentTask() const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const createTask = vi.fn() - const handleModeSwitch = vi.fn().mockRejectedValue(new Error("mode switch failed")) + const prepareProviderHandoffContext = vi.fn().mockRejectedValue(new Error("handoff preparation failed")) const providerEmit = vi.fn() const taskHistoryStore = makeStoreStub() - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: providerEmit, getCurrentTask: vi.fn(() => parentTask), removeClineFromStack, createTask, - handleModeSwitch, + prepareProviderHandoffContext, log: vi.fn(), isViewLaunched: false, recentTasksCache: undefined, taskHistoryStore, - } as unknown as ClineProvider + }) await expect( ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { @@ -158,7 +268,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { initialTodos: [], mode: "code", }), - ).rejects.toThrow("mode switch failed") + ).rejects.toThrow("handoff preparation failed") // Fail closed before the stack changes: the parent was never removed, so it // remains the current task. @@ -172,6 +282,50 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(providerEmit).not.toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", expect.anything()) }) + it("restores the parent when child creation fails after the parent was removed", async () => { + const parentTask = makeParentTask() + const creationError = new Error("child creation failed") + const createTask = vi.fn().mockRejectedValue(creationError) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }) + const providerEmit = vi.fn() + const taskHistoryStore = makeStoreStub() + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + getTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(creationError) + + // The original creation error is preserved and the parent is restored. + expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem, { + transitionOwner: expect.anything(), + }) + + // No delegation metadata was committed and nothing was emitted. + expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled() + expect(providerEmit).not.toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", expect.anything()) + }) + it("rolls back when pending-action ownership changes before the atomic parent update", async () => { const pendingAction = { kind: "create_subtask" as const, @@ -191,6 +345,12 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { const replacementAction = { ...pendingAction, actionId: "replacement-action" } const taskHistoryStore = makeStoreStub({ get: vi.fn().mockReturnValue({ ...parentHistoryItem, status: "active", pendingAction }), + // The strict fresh read mirrors the unchanged durable record: the + // updater rejected before anything was written. + readFresh: vi.fn(async () => ({ + kind: "found" as const, + item: { ...parentHistoryItem, status: "active", pendingAction }, + })), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { updater({ ...parentHistoryItem, status: "active", pendingAction: replacementAction }) return [] @@ -198,20 +358,20 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const deleteTaskWithId = vi.fn().mockResolvedValue(undefined) const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask, removeClineFromStack: vi.fn().mockResolvedValue(undefined), createTask, - handleModeSwitch: vi.fn().mockResolvedValue(undefined), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), deleteTaskWithId, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), createTaskWithHistoryItem, log: vi.fn(), isViewLaunched: false, taskHistoryStore, - } as unknown as ClineProvider + }) await expect( ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { @@ -225,33 +385,35 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(child.run).not.toHaveBeenCalled() expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) - expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) + expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem, { + transitionOwner: expect.anything(), + }) }) it("persists parent delegation metadata via atomicReadAndUpdate and emits TaskDelegated", async () => { const providerEmit = vi.fn() const parentTask = makeParentTask() + const prepared = makePreparedHandoff() - const childRun = vi.fn().mockResolvedValue(undefined) + const child = makeChildTask("child-1") const removeClineFromStack = vi.fn().mockResolvedValue(undefined) - const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: childRun }) - const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue(child) const taskHistoryStore = makeStoreStub() - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: providerEmit, getCurrentTask: vi.fn(() => parentTask), removeClineFromStack, createTask, - handleModeSwitch, + prepareProviderHandoffContext: makePreparationStub(prepared), log: vi.fn(), isViewLaunched: false, recentTasksCache: undefined, taskHistoryStore, - } as unknown as ClineProvider + }) - const child = await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + const result = await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { parentTaskId: "parent-1", message: "Do something", initialTodos: [], @@ -259,16 +421,22 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) await Promise.resolve() // drain scheduler microtask so child.run() is invoked - expect(child.taskId).toBe("child-1") + expect(result.taskId).toBe("child-1") // Invariant: parent closed before child creation expect(removeClineFromStack).toHaveBeenCalledTimes(1) - // Child task created with startTask: false and initialStatus: "active" + // Child task created from the all-or-none explicit handoff execution + // context with startTask: false expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, { initialTodos: [], initialStatus: "active", startTask: false, + handoffExecutionContext: { + mode: prepared.requestedMode, + apiConfigName: prepared.profile.name, + apiConfiguration: expect.anything(), + }, }) // Delegation metadata written via atomicReadAndUpdate with correct taskId @@ -277,8 +445,8 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(calledTaskId).toBe("parent-1") // The updater must produce the correct delegation fields - const result = updater(parentHistoryItem) - expect(result).toMatchObject({ + const delegated = updater(parentHistoryItem) + expect(delegated).toMatchObject({ id: "parent-1", status: "delegated", delegatedToId: "child-1", @@ -286,18 +454,18 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { childIds: expect.arrayContaining(["child-1"]), }) + // The prepared context became authoritative on the paused child after the commit. + expect(child.adoptHandoffExecutionContext).toHaveBeenCalledWith({ + mode: prepared.requestedMode, + apiConfigName: prepared.profile.name, + apiConfiguration: expect.anything(), + }) + // child.run() called AFTER parent metadata is persisted (via taskScheduler) - expect(childRun).toHaveBeenCalledTimes(1) + expect(child.run).toHaveBeenCalledTimes(1) // Provider-level event expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") - - // The parent has already been removed, so the mode switch must not publish a - // transient empty-task state before the child is created. - const handoff = createProviderHandoffPlan("code") - expect(handleModeSwitch).toHaveBeenCalledWith(handoff.requestedMode, handoff.policy.targetTask, { - pendingHandoff: handoff.policy, - }) }) it("posts taskHistoryItemUpdated to the webview when isViewLaunched is true", async () => { @@ -308,21 +476,21 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { get: vi.fn().mockReturnValue(updatedParent), }) - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => parentTask), removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: () => Promise.resolve() }), - handleModeSwitch: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(makeChildTask("child-1")), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), postMessageToWebview, log: vi.fn(), isViewLaunched: true, recentTasksCache: undefined, taskHistoryStore, - } as unknown as ClineProvider + }) - await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { parentTaskId: "parent-1", message: "Do something", initialTodos: [], @@ -342,21 +510,21 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { get: vi.fn().mockReturnValue(undefined), }) - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => parentTask), removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: () => Promise.resolve() }), - handleModeSwitch: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(makeChildTask("child-1")), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), postMessageToWebview, log: vi.fn(), isViewLaunched: true, recentTasksCache: undefined, taskHistoryStore, - } as unknown as ClineProvider + }) - await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { parentTaskId: "parent-1", message: "Do something", initialTodos: [], @@ -370,14 +538,18 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { const callOrder: string[] = [] const parentTask = makeParentTask() - const childRun = vi.fn(async () => callOrder.push("child.run")) + const child = makeChildTask("child-1") + child.run.mockImplementation(async () => { + callOrder.push("child.run") + }) const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const createTask = vi.fn(async () => { callOrder.push("createTask") - return { taskId: "child-1", start: vi.fn(), run: childRun } + return child }) - const handleModeSwitch = vi.fn(async () => { - callOrder.push("handleModeSwitch") + const prepareProviderHandoffContext = vi.fn(async () => { + callOrder.push("prepareProviderHandoffContext") + return makePreparedHandoff() }) const taskHistoryStore = makeStoreStub({ atomicReadAndUpdate: vi.fn(async (_taskId: string, _updater: (h: HistoryItem) => HistoryItem) => { @@ -386,20 +558,20 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), }) - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => parentTask), removeClineFromStack, createTask, - handleModeSwitch, + prepareProviderHandoffContext, log: vi.fn(), isViewLaunched: false, recentTasksCache: undefined, taskHistoryStore, - } as unknown as ClineProvider + }) - await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { parentTaskId: "parent-1", message: "Do something", initialTodos: [], @@ -407,10 +579,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) await Promise.resolve() // drain scheduler microtask so child.run() is invoked - // handleModeSwitch → createTask → atomicReadAndUpdate → child.run: the mode - // handoff completes before the parent leaves the stack, and the scheduler - // admits the child only after metadata is persisted - expect(callOrder).toEqual(["handleModeSwitch", "createTask", "atomicReadAndUpdate", "child.run"]) + // prepare → createTask → atomicReadAndUpdate → child.run: read-only + // preparation completes before the parent leaves the stack, and the + // scheduler admits the child only after metadata is persisted + expect(callOrder).toEqual(["prepareProviderHandoffContext", "createTask", "atomicReadAndUpdate", "child.run"]) }) it("implicitly severs interrupted awaited child and re-delegates when parent is already delegated", async () => { @@ -435,20 +607,20 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), }) - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => makeParentTask()), removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTask: vi.fn().mockResolvedValue({ taskId: "child-2", start: vi.fn(), run: () => Promise.resolve() }), - handleModeSwitch: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(makeChildTask("child-2")), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), log: vi.fn(), isViewLaunched: false, recentTasksCache: undefined, taskHistoryStore, - } as unknown as ClineProvider + }) - await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { parentTaskId: "parent-1", message: "Continue", initialTodos: [], @@ -478,7 +650,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { delegatedToId: oldChildId, } as unknown as HistoryItem - const child = { taskId: "child-2", start: vi.fn(), run: vi.fn().mockResolvedValue(undefined) } + const child = makeChildTask("child-2") const getCurrentTask = vi.fn().mockReturnValue(makeParentTask()) const createTask = vi.fn().mockImplementation(async () => { getCurrentTask.mockReturnValue(child) @@ -489,6 +661,13 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { get: vi.fn((id: string) => id === "parent-1" ? alreadyDelegatedParent : id === oldChildId ? activeChild : undefined, ), + // The strict fresh read mirrors the unchanged durable record: the + // updater rejected before anything was written. + readFresh: vi.fn(async (taskId: string) => + taskId === "parent-1" + ? { kind: "found" as const, item: alreadyDelegatedParent } + : { kind: "missing" as const }, + ), // Real atomicReadAndUpdate behaviour: call the updater and propagate any throw atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { updater(alreadyDelegatedParent) @@ -496,13 +675,13 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), }) - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask, removeClineFromStack: vi.fn().mockResolvedValue(undefined), createTask, - handleModeSwitch: vi.fn().mockResolvedValue(undefined), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), deleteTaskWithId: vi.fn().mockResolvedValue(undefined), getTaskWithId: vi.fn().mockResolvedValue({ historyItem: alreadyDelegatedParent }), createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined), @@ -510,10 +689,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { isViewLaunched: false, recentTasksCache: undefined, taskHistoryStore, - } as unknown as ClineProvider + }) await expect( - (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { parentTaskId: "parent-1", message: "Continue", initialTodos: [], @@ -529,7 +708,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { it("rolls back the paused child and restores the parent when atomicReadAndUpdate fails", async () => { const persistError = new Error("parent metadata persist failed") const parentTask = makeParentTask() - const childRun = vi.fn().mockResolvedValue(undefined) + const child = makeChildTask("child-1") const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const deleteTaskWithId = vi.fn().mockResolvedValue(undefined) const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) @@ -539,8 +718,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { atomicReadAndUpdate: vi.fn().mockRejectedValue(persistError), }) - const child = { taskId: "child-1", start: vi.fn(), run: childRun } - // Before createTask: getCurrentTask returns parent (used by step 3 close). + // Before createTask: getCurrentTask returns parent (used by step 4 close). // After createTask: returns child so the rollback guard passes and the child is popped. const getCurrentTask = vi.fn().mockReturnValue(parentTask) const createTask = vi.fn().mockImplementation(async () => { @@ -548,24 +726,24 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { return child }) - const provider = { + const provider = makeProviderStub({ taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask, removeClineFromStack, createTask, getTaskWithId, - handleModeSwitch: vi.fn().mockResolvedValue(undefined), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), deleteTaskWithId, createTaskWithHistoryItem, log: vi.fn(), isViewLaunched: false, recentTasksCache: undefined, taskHistoryStore, - } as unknown as ClineProvider + }) await expect( - (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { parentTaskId: "parent-1", message: "Do something", initialTodos: [], @@ -573,10 +751,2271 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), ).rejects.toThrow(persistError) - expect(childRun).not.toHaveBeenCalled() + expect(child.run).not.toHaveBeenCalled() expect(removeClineFromStack).toHaveBeenNthCalledWith(1) expect(removeClineFromStack).toHaveBeenNthCalledWith(2) expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) - expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) + expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem, { + transitionOwner: expect.anything(), + }) + }) + + it("wraps an incomplete rollback in an AggregateError that preserves the original error first", async () => { + const persistError = new Error("parent metadata persist failed") + const cleanupError = new Error("child cleanup failed") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const deleteTaskWithId = vi.fn().mockRejectedValue(cleanupError) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }) + + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn().mockRejectedValue(persistError), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + getTaskWithId, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + const error: unknown = await ClineProvider.prototype.delegateParentAndOpenChild + .call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + .catch((caught: unknown) => caught) + + if (!(error instanceof AggregateError)) { + throw new Error(`expected an AggregateError, got: ${String(error)}`) + } + + // Original commit failure first, then the failed rollback steps. + expect(error.errors).toEqual([persistError, cleanupError]) + expect(error.message).toContain("parent metadata persist failed") + expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem, { + transitionOwner: expect.anything(), + }) + }) + + it("projects global state only after the delegation commit and still starts the child when projection fails", async () => { + const callOrder: string[] = [] + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + child.run.mockImplementation(async () => { + callOrder.push("child.run") + }) + const providerEmit = vi.fn() + const updateGlobalState = vi.fn(async (key: string) => { + callOrder.push(`update:${key}`) + }) + const setProviderSettings = vi.fn(async () => { + callOrder.push("setProviderSettings") + }) + const projectHandoffState = vi.fn(async () => { + callOrder.push("projectHandoffState") + }) + const listConfig = vi.fn(async () => { + callOrder.push("listConfig") + return [] + }) + + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + callOrder.push("commit") + updater(parentHistoryItem) + return [] + }), + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + // The real bounded queue: generation bookkeeping must be live for the + // projection-result fences under test. + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState, + contextProxy: { setProviderSettings }, + providerSettingsManager: { listConfig, projectHandoffState }, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + + // The child starts immediately after the commit WITHOUT awaiting the + // legacy projection. + expect(callOrder.indexOf("commit")).toBeLessThan(callOrder.indexOf("child.run")) + expect(child.run).toHaveBeenCalledTimes(1) + + // Deterministically await the exposed background-projection hook. + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + + // No global write happens before the durable delegation commit... + expect(callOrder.indexOf("commit")).toBeLessThan(callOrder.indexOf("update:mode")) + expect(callOrder.indexOf("commit")).toBeLessThan(callOrder.indexOf("update:currentApiConfigName")) + expect(callOrder.indexOf("commit")).toBeLessThan(callOrder.indexOf("setProviderSettings")) + // ...and child.run precedes every projection write. + expect(callOrder).toContain("update:mode") + expect(callOrder).toContain("projectHandoffState") + + // ...the durable mode mapping intent is projected with the prepared + // profile as an explicit set intent... + expect(projectHandoffState).toHaveBeenCalledWith({ + intent: { kind: "set", name: "profile-1" }, + mode: "code", + modeConfigId: "profile-1-id", + }) + // ...and the delegation is announced. + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") + }) + + it("advances the shared handoff protocol to child-running on the happy path", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + // Deterministically await the exposed background-projection hook. + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + // Publication is asynchronous and policy-gated outside the method, so + // the last landmark is child-running with the background projection + // recorded as synchronized. + expect(protocol?.snapshot()).toMatchObject({ + phase: "child-running", + delegation: "committed", + contextAuthority: "child", + childPresence: "running", + projection: "synchronized", + publication: "none", + commitAttempts: 1, + }) + }) + + it("records child-running with an unresolved projection before the background projection settles", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + let releaseProjection!: (outcome: ProviderHandoffProjectionOutcome) => void + const projectionGate = new Promise((resolve) => { + releaseProjection = resolve + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockReturnValue(projectionGate), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await Promise.resolve() + + const protocolState = ( + provider as unknown as { + providerHandoffProtocol?: { snapshot(): { phase: string; projection: string } } + } + ).providerHandoffProtocol?.snapshot() + // The child is already running while the legacy projection is still + // unresolved; the child start never awaits the background work. + expect(protocolState).toMatchObject({ phase: "child-running", projection: "original" }) + expect(child.run).toHaveBeenCalledTimes(1) + + releaseProjection({ ok: true }) + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + expect( + ( + provider as unknown as { + providerHandoffProtocol?: { snapshot(): { phase: string; projection: string } } + } + ).providerHandoffProtocol?.snapshot(), + ).toMatchObject({ phase: "child-running", projection: "synchronized" }) + }) + + it("records a clean abort landmark when preparation rejects", async () => { + const parentTask = makeParentTask() + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn(), + prepareProviderHandoffContext: vi.fn().mockRejectedValue(new Error("handoff preparation failed")), + log: vi.fn(), + isViewLaunched: false, + taskHistoryStore: makeStoreStub(), + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow("handoff preparation failed") + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + expect(protocol?.snapshot()).toMatchObject({ + phase: "aborted", + failure: { boundary: "preparation" }, + parentPresence: "current", + childPresence: "absent", + }) + }) + + it("reconciles a rejected commit as uncommitted and records the resolved abort landmarks", async () => { + const persistError = new Error("parent metadata persist failed") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn().mockRejectedValue(persistError), + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + deleteTaskWithId: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(persistError) + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + // The strict fresh parent read shows the exact nondelegated preimage + // (child history absent, as at any real commit boundary), so the + // rejection is authoritatively uncommitted and the rollback settles. + expect(protocol?.snapshot()).toMatchObject({ + phase: "aborted", + commitAttempts: 1, + failure: { boundary: "delegation-commit", commitDurability: "uncommitted", commitObservation: "unchanged" }, + parentPresence: "restored", + childPresence: "absent", + delegation: "none", + rollbackFailures: [], + }) + expect(taskHistoryStore.readFresh).toHaveBeenCalledWith("parent-1") + }) + + it("starts the committed child and keeps it current when the post-commit projection fails", async () => { + const projectionError = new Error("projection failed") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + const log = vi.fn() + + const taskHistoryStore = makeStoreStub() + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub( + makePreparedHandoff({ + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: SENTINEL_API_KEY, + }, + }), + ), + // The real bounded queue: generation bookkeeping must be live for the + // projection-result fences under test. + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState: vi.fn().mockResolvedValue(undefined), + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockRejectedValue(projectionError), + projectHandoffState: vi.fn().mockResolvedValue(undefined), + }, + log, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + // The projection failure must not reject the delegation. + const result = await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + // Deterministically await the exposed background-projection hook. + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + + expect(result).toBe(child) + // Delegation was committed and announced; the child started. + expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1) + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") + expect(child.run).toHaveBeenCalledTimes(1) + // The failure was logged redacted — never with the sentinel secret value. + const logged = log.mock.calls.map((call) => call.join(" ")).join("\n") + expect(logged).toContain("Post-commit handoff projection failed") + expect(logged).toContain("projection failed") + expect(logged).not.toContain(SENTINEL_API_KEY) + // The prepared context is still authoritative on the child. + expect(child.adoptHandoffExecutionContext).toHaveBeenCalled() + }) + + it("treats a write-then-reject commit as observed committed and keeps the durable delegation", async () => { + const persistError = new Error("store rejected after persisting") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + const deleteTaskWithId = vi.fn() + const createTaskWithHistoryItem = vi.fn() + + // The store write persisted the delegation, then the store rejected. + // Production-realistic: the parent task file is durably delegated while + // the child's own history has not been written yet at all. + const delegatedParent = { ...parentHistoryItem, status: "delegated", awaitingChildId: "child-1" } + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async () => { + throw persistError + }), + readFresh: vi.fn(async (taskId: string) => + taskId === "parent-1" + ? { kind: "found" as const, item: delegatedParent } + : { kind: "missing" as const }, + ), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + const result = await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + + expect(result).toBe(child) + // No destructive rollback over the committed lineage. + expect(deleteTaskWithId).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(child.run).toHaveBeenCalledTimes(1) + expect(child.adoptHandoffExecutionContext).toHaveBeenCalled() + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") + // The committed child continues despite the missing child history. + expect(taskHistoryStore.readFresh).toHaveBeenCalledWith("child-1") + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + expect(protocol?.snapshot()).toMatchObject({ + phase: "child-running", + delegation: "committed", + contextAuthority: "child", + childPresence: "running", + commitAttempts: 1, + failure: { boundary: "delegation-commit", commitDurability: "committed" }, + }) + }) + + it("observes a reject-before-write commit as uncommitted and rolls back", async () => { + const persistError = new Error("store rejected before writing") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const deleteTaskWithId = vi.fn().mockResolvedValue(undefined) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) + const activeParent = { ...parentHistoryItem, status: "active" } + + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async () => { + // The rejection happened before any persisted write. + throw persistError + }), + // The preimage source (cache) and the strict fresh disk read agree: + // an active, nondelegated parent record. + get: vi.fn((taskId: string) => (taskId === "parent-1" ? activeParent : undefined)), + readFresh: vi.fn(async (taskId: string) => + taskId === "parent-1" ? { kind: "found" as const, item: activeParent } : { kind: "missing" as const }, + ), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(persistError) + + expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) + expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem, { + transitionOwner: expect.anything(), + }) + expect(child.run).not.toHaveBeenCalled() + }) + + it("surfaces a degraded state without destructive rollback when the reconciliation re-read fails", async () => { + const persistError = new Error("parent metadata persist failed") + const readFailure = new Error("store unreadable during reconciliation") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const deleteTaskWithId = vi.fn() + const createTaskWithHistoryItem = vi.fn() + + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async () => { + throw persistError + }), + readFresh: vi.fn().mockRejectedValue(readFailure), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + const error: unknown = await ClineProvider.prototype.delegateParentAndOpenChild + .call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + .catch((caught: unknown) => caught) + + if (!(error instanceof AggregateError)) { + throw new Error(`expected an AggregateError, got: ${String(error)}`) + } + // The original commit failure is retained first; the unreadable-parent + // observation follows. A strict-read failure is incoherent, never + // collapsed into "missing". + expect(error.errors).toEqual([persistError, readFailure]) + expect(error.message).toContain("durability could not be determined") + + // Nothing was destructively rolled back: the child stays paused and + // the parent record is never restored over potentially committed lineage. + expect(deleteTaskWithId).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(child.run).not.toHaveBeenCalled() + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + expect(protocol?.snapshot()).toMatchObject({ + phase: "degraded-abort", + childPresence: "paused", + parentPresence: "removed", + delegation: "none", + commitAttempts: 1, + failure: { boundary: "delegation-commit", commitDurability: "incoherent" }, + }) + }) + + it("keeps the paused child and avoids rollback when the child lineage does not match", async () => { + const persistError = new Error("parent metadata persist failed") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const deleteTaskWithId = vi.fn() + const createTaskWithHistoryItem = vi.fn() + + // The parent record claims this exact child, but the child record that + // exists contradicts the lineage: durability is unknowable and the + // reconciliation must be non-destructive. + const delegatedParent = { ...parentHistoryItem, status: "delegated", awaitingChildId: "child-1" } + const contradictoryChild = { ...parentHistoryItem, id: "child-1", parentTaskId: "other-parent" } + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async () => { + throw persistError + }), + readFresh: vi.fn(async (taskId: string) => { + if (taskId === "parent-1") return { kind: "found" as const, item: delegatedParent } + if (taskId === "child-1") return { kind: "found" as const, item: contradictoryChild } + return { kind: "missing" as const } + }), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(AggregateError) + + expect(deleteTaskWithId).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(child.run).not.toHaveBeenCalled() + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + expect(protocol?.snapshot()).toMatchObject({ + phase: "degraded-abort", + childPresence: "paused", + parentPresence: "removed", + failure: { + boundary: "delegation-commit", + commitDurability: "incoherent", + commitObservation: "contradictory-child", + }, + }) + }) + + it("keeps the paused child when the parent record shows a delegation to another child", async () => { + const persistError = new Error("parent metadata persist failed") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const deleteTaskWithId = vi.fn() + const createTaskWithHistoryItem = vi.fn() + + // Another writer delegated the parent to a different child: rolling + // back would destroy someone else's committed lineage. + const otherDelegation = { ...parentHistoryItem, status: "delegated", awaitingChildId: "child-other" } + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async () => { + throw persistError + }), + readFresh: vi.fn(async (taskId: string) => + taskId === "parent-1" + ? { kind: "found" as const, item: otherDelegation } + : { kind: "missing" as const }, + ), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(AggregateError) + + expect(deleteTaskWithId).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(child.run).not.toHaveBeenCalled() + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + expect(protocol?.snapshot()).toMatchObject({ + phase: "degraded-abort", + failure: { + boundary: "delegation-commit", + commitDurability: "incoherent", + commitObservation: "other-child", + }, + }) + }) + + it("keeps the paused child when the parent record drifted from the safe nondelegated preimage", async () => { + const persistError = new Error("parent metadata persist failed") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const deleteTaskWithId = vi.fn() + const createTaskWithHistoryItem = vi.fn() + + // The parent is non-delegated but no longer matches the preimage that + // was captured before the commit attempt (a new childIds entry appeared + // from another writer): durability is unknowable, so no rollback. + const driftedParent = { ...parentHistoryItem, status: "active", childIds: ["unrelated-child"] } + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async () => { + throw persistError + }), + readFresh: vi.fn(async (taskId: string) => + taskId === "parent-1" ? { kind: "found" as const, item: driftedParent } : { kind: "missing" as const }, + ), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(AggregateError) + + expect(deleteTaskWithId).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(child.run).not.toHaveBeenCalled() + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + expect(protocol?.snapshot()).toMatchObject({ + phase: "degraded-abort", + failure: { + boundary: "delegation-commit", + commitDurability: "incoherent", + commitObservation: "drifted", + }, + }) + }) + + it("treats a delegated parent with missing child history as committed and continues the child", async () => { + const persistError = new Error("store rejected after persisting") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + + // The rejected write persisted the exact parent delegation; the child + // record is absent — expected at the commit boundary, and the parent + // record alone is authoritative for the committed observation. + const delegatedParent = { ...parentHistoryItem, status: "delegated", awaitingChildId: "child-1" } + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async () => { + throw persistError + }), + readFresh: vi.fn(async (taskId: string) => + taskId === "parent-1" + ? { kind: "found" as const, item: delegatedParent } + : { kind: "missing" as const }, + ), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + deleteTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + const result = await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + + expect(result).toBe(child) + expect(child.run).toHaveBeenCalledTimes(1) + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + expect(protocol?.snapshot()).toMatchObject({ + phase: "child-running", + delegation: "committed", + failure: { + boundary: "delegation-commit", + commitDurability: "committed", + commitObservation: "exact", + }, + }) + }) + + it("keeps the paused child when the strict parent read reports an unreadable record", async () => { + const persistError = new Error("parent metadata persist failed") + const parseFailure = new Error("parent task file is not valid JSON") + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const deleteTaskWithId = vi.fn() + const createTaskWithHistoryItem = vi.fn() + + // The strict read distinguishes an unreadable parent record from a + // definitively missing one: unreadable is incoherent, not uncommitted. + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async () => { + throw persistError + }), + readFresh: vi.fn(async () => ({ kind: "error" as const, reason: "parse" as const, error: parseFailure })), + }) + + const getCurrentTask = vi.fn().mockReturnValue(parentTask) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + const error: unknown = await ClineProvider.prototype.delegateParentAndOpenChild + .call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + .catch((caught: unknown) => caught) + + if (!(error instanceof AggregateError)) { + throw new Error(`expected an AggregateError, got: ${String(error)}`) + } + expect(error.errors).toEqual([persistError, parseFailure]) + expect(deleteTaskWithId).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(child.run).not.toHaveBeenCalled() + + const protocol = (provider as unknown as { providerHandoffProtocol?: { snapshot(): { phase: string } } }) + .providerHandoffProtocol + expect(protocol?.snapshot()).toMatchObject({ + phase: "degraded-abort", + childPresence: "paused", + failure: { boundary: "delegation-commit", commitDurability: "incoherent", commitObservation: "unreadable" }, + }) + }) + + it("starts the child after the delegation and lets the projection fail afterwards", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const log = vi.fn() + const projectionError = new Error("boom-listConfig-provider-detail") + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState: vi.fn().mockResolvedValue(undefined), + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockRejectedValue(projectionError), + projectHandoffState: vi.fn().mockResolvedValue(undefined), + }, + log, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + + // The child started even though the background projection failed, and + // the failure was logged as a stable boundary/category — never with + // provider-originated error text or the sentinel secret. + expect(child.run).toHaveBeenCalledTimes(1) + const logged = log.mock.calls.map((call) => call.join(" ")).join("\n") + expect(logged).toContain("Post-commit handoff projection failed") + expect(logged).toContain("profile-meta-read (Error)") + // The provider-originated message text is never interpolated. + expect(logged).not.toContain("boom-listConfig-provider-detail") + expect(logged).not.toContain(SENTINEL_API_KEY) + }) + + it("performs zero writes when a queued projection is cancelled before it starts", async () => { + vi.useFakeTimers() + try { + const provider = makeProviderStub({ log: vi.fn() }) + // First operation starts immediately and hangs: it owns the queue + // tail even past its timeout (non-cancellable underlying write). + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const firstWrite = vi.fn(() => firstGate) + const secondWrite = vi.fn(async () => "second") + const thirdWrite = vi.fn(async () => "third") + + const first = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, firstWrite) + const second = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, secondWrite) + const firstOutcome = first.then( + () => "resolved", + (error: unknown) => (error as Error).message as string, + ) + const secondOutcome = second.then( + () => "resolved", + (error: unknown) => (error as Error).message as string, + ) + + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + + // Both callers are released at the bounded timeout... + expect(await firstOutcome).toContain("timed out") + expect(await secondOutcome).toContain("timed out") + // ...but the second was cancelled BEFORE it started: zero writes. + expect(firstWrite).toHaveBeenCalledTimes(1) + expect(secondWrite).not.toHaveBeenCalled() + + // A newer write (fresh timeout window) still cannot overtake the + // started, hung first write: the admission-aborted second did not + // release the queue past the first operation's owned tail. + const third = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, thirdWrite) + await vi.advanceTimersByTimeAsync(1) + expect(thirdWrite).not.toHaveBeenCalled() + + // Once the started write settles, the queue it owns advances — and + // the admission-aborted second callback is skipped without running. + releaseFirst() + await vi.advanceTimersByTimeAsync(0) + expect(secondWrite).not.toHaveBeenCalled() + expect(thirdWrite).toHaveBeenCalledTimes(1) + await expect(third).resolves.toBe("third") + } finally { + vi.useRealTimers() + } + }) + + it("serializes a newer mutation behind a started hung write even after the caller times out", async () => { + vi.useFakeTimers() + try { + const provider = makeProviderStub({ log: vi.fn() }) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const firstWrite = vi.fn(async () => { + await firstGate + return "first" + }) + const secondWrite = vi.fn(async () => "second") + + const first = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, firstWrite) + // Attach the outcome mapping before the timer can fire so the + // rejection always has a handler. + const firstOutcome = first.then( + () => "resolved", + (error: unknown) => String(error), + ) + + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + + // The timed-out caller is released... + await expect(firstOutcome).resolves.toContain("timed out") + + // ...but the queue tail stays owned by the started write. A newer + // mutation enqueued afterwards is not admitted and cannot overtake it. + const second = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, secondWrite) + expect(firstWrite).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(secondWrite).not.toHaveBeenCalled() + + releaseFirst() + await vi.advanceTimersByTimeAsync(0) + // Only after the underlying write settles does the newer mutation run. + expect(secondWrite).toHaveBeenCalledTimes(1) + await expect(second).resolves.toBe("second") + } finally { + vi.useRealTimers() + } + }) + + it("serializes two same-parent delegations so the second observes the committed delegation", async () => { + const parentTask = makeParentTask() + const firstChild = makeChildTask("child-1") + const deleteTaskWithId = vi.fn() + + let releaseCommit!: () => void + const commitGate = new Promise((resolve) => { + releaseCommit = resolve + }) + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + await commitGate + updater(parentHistoryItem) + return [] + }), + }) + + let currentTask: unknown = parentTask + const getCurrentTask = vi.fn(() => currentTask) + const createTask = vi.fn().mockImplementation(async () => { + currentTask = firstChild + return firstChild + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + deleteTaskWithId, + createTaskWithHistoryItem: vi.fn(), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + const first = ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "First", + initialTodos: [], + mode: "code", + }) + const second = ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Second", + initialTodos: [], + mode: "code", + }) + + // The first delegation holds the per-parent transition lock at the + // commit; the second must not have started any side effects yet. + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + expect(createTask).toHaveBeenCalledTimes(1) + + releaseCommit() + await first + + // The queued second call observes the child as current and can neither + // re-delegate nor remove the first delegation's child. + await expect(second).rejects.toThrow(/Parent mismatch/) + expect(createTask).toHaveBeenCalledTimes(1) + expect(deleteTaskWithId).not.toHaveBeenCalled() + }) + + it("holds the per-parent lock so a completion cannot interleave with an in-flight delegation", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const order: string[] = [] + + let releaseCommit!: () => void + const commitGate = new Promise((resolve) => { + releaseCommit = resolve + }) + const records = new Map([["parent-1", { ...parentHistoryItem }]]) + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + await commitGate + records.set("parent-1", updater(structuredClone(records.get("parent-1")!))) + order.push("committed") + return [] + }), + get: vi.fn((taskId: string) => records.get(taskId)), + }) + const getTaskWithId = vi.fn(async (id: string) => { + order.push(`read:${id}`) + return { historyItem: records.get(id) } + }) + + let currentTask: unknown = parentTask + const getCurrentTask = vi.fn(() => currentTask) + const createTask = vi.fn().mockImplementation(async () => { + currentTask = child + return child + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + getTaskWithId, + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + deleteTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + contextProxy: { globalStorageUri: { fsPath: "/test/global-storage" } }, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + const delegation = ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "First", + initialTodos: [], + mode: "code", + }) + // A completion for a different child starts while the delegation is + // mid-transition; it must wait for the lock and then be rejected by the + // delegation-ownership guard, never interleaving with the commit. + const completion = ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-other", + completionResultSummary: "done", + }) + + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + expect(order).toEqual([]) + + releaseCommit() + await delegation + expect(await completion).toBe(false) + // The completion's store read happened only after the delegation committed. + expect(order[0]).toBe("committed") + expect(order[1]).toBe("read:parent-1") + }) + + it("starts the child after a timed-out projection and ignores the late completion", async () => { + vi.useFakeTimers() + try { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + let releaseWrite!: () => void + const writeGate = new Promise((resolve) => { + releaseWrite = resolve + }) + const log = vi.fn() + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + // Real projection on the real bounded queue: the first legacy + // write hangs and ignores the abort, so only the timeout can + // release the queue and let the delegation continue. + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState: vi.fn(() => writeGate), + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockResolvedValue([]), + projectHandoffState: vi.fn().mockResolvedValue(undefined), + }, + log, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + }) + + const delegation = ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + // The delegation resolves without awaiting the projection at all: + // the child starts immediately, before any timeout fires. + await expect(delegation).resolves.toBe(child) + expect(child.run).toHaveBeenCalledTimes(1) + expect( + (provider as unknown as { staleProviderHandoffProjection?: unknown }).staleProviderHandoffProjection, + ).toBeUndefined() + + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + // The bounded queue released the caller at the timeout while the + // started write stayed owned; the abandoned projection stamped the + // generation-fenced stale marker. + const marker = (provider as unknown as { staleProviderHandoffProjection?: { requestedMode: string } }) + .staleProviderHandoffProjection + expect(marker).toMatchObject({ requestedMode: "code" }) + + // The hung write eventually settles — the late completion is + // inert: it neither clears the marker nor emits a mode change. + releaseWrite() + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + await vi.advanceTimersByTimeAsync(0) + const markerAfterLateCompletion = ( + provider as unknown as { staleProviderHandoffProjection?: { requestedMode: string } } + ).staleProviderHandoffProjection + expect(markerAfterLateCompletion).toMatchObject({ requestedMode: "code" }) + expect(providerEmit).not.toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "code") + const logged = log.mock.calls.map((call) => call.join(" ")).join("\n") + expect(logged).toContain("completed after cancellation") + } finally { + vi.useRealTimers() + } + }) + + it("supersedes a stale projection marker when a later profile mutation succeeds", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const projectionError = new Error("listConfig failed") + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState: vi.fn().mockResolvedValue(undefined), + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockRejectedValue(projectionError), + projectHandoffState: vi.fn().mockResolvedValue(undefined), + }, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + + const providerState = provider as unknown as { + staleProviderHandoffProjection?: { requestedMode: string; generation: number } + providerProfileMutationSettledGeneration: number + } + expect(providerState.staleProviderHandoffProjection).toMatchObject({ requestedMode: "code" }) + const markerGeneration = providerState.staleProviderHandoffProjection?.generation + + // Any later successful mode/profile mutation on the queue — the same + // path a user-driven mode or profile switch takes — supersedes the + // stale marker through the generation fence. + await ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, async () => undefined) + + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + expect(providerState.providerProfileMutationSettledGeneration).toBeGreaterThan(markerGeneration ?? 0) + }) + + it("completes same-parent restoration under a held lock with exactly one interruption (no deadlock)", async () => { + const activeChildHistory = { + ...parentHistoryItem, + id: "child-1", + status: "active", + parentTaskId: "parent-1", + } as unknown as HistoryItem + const childTask = { taskId: "child-1" } + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const updateTaskHistory = vi.fn().mockResolvedValue(undefined) + const postMessageToWebview = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn(async (taskId: string) => ({ + historyItem: + taskId === "parent-1" + ? { ...parentHistoryItem, status: "delegated", awaitingChildId: "child-1" } + : activeChildHistory, + })) + + const provider = makeProviderStub({ + log: vi.fn(), + getCurrentTask: vi.fn(() => childTask), + removeClineFromStack, + updateTaskHistory, + postMessageToWebview, + getTaskWithId, + taskHistoryStore: makeStoreStub({ + get: vi.fn((taskId: string) => (taskId === "child-1" ? activeChildHistory : undefined)), + }), + }) + + // Restoration under the already-held parent lock: the restoration path + // evicts the current active persisted child of the SAME parent, whose + // interruption must run the unlocked core instead of re-acquiring the + // lock the caller owns. Before the transition-owner token this test + // deadlocked until the test timeout — completion at all is the proof. + const held = ClineProvider.prototype["runDelegationTransition"].call( + provider, + "parent-1", + async (owner: symbol) => { + // What createTaskWithHistoryItem(..., { transitionOwner }) does + // before installing the restored parent: evict the current task. + await ClineProvider.prototype.evictCurrentTask.call(provider, owner) + return "restored" + }, + ) + + expect(await held).toBe("restored") + + // Exactly one interruption was recorded for the evicted child. + expect(updateTaskHistory).toHaveBeenCalledTimes(1) + expect(vi.mocked(updateTaskHistory).mock.calls[0][0]).toMatchObject({ + id: "child-1", + status: "interrupted", + }) + }) + + it("serializes ordinary external eviction behind a held parent lock", async () => { + const activeChildHistory = { + ...parentHistoryItem, + id: "child-1", + status: "active", + parentTaskId: "parent-1", + } as unknown as HistoryItem + const childTask = { taskId: "child-1" } + const updateTaskHistory = vi.fn().mockResolvedValue(undefined) + const postMessageToWebview = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn(async (taskId: string) => ({ + historyItem: + taskId === "parent-1" + ? { ...parentHistoryItem, status: "delegated", awaitingChildId: "child-1" } + : activeChildHistory, + })) + + const provider = makeProviderStub({ + log: vi.fn(), + getCurrentTask: vi.fn(() => childTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + updateTaskHistory, + postMessageToWebview, + getTaskWithId, + taskHistoryStore: makeStoreStub({ + get: vi.fn((taskId: string) => (taskId === "child-1" ? activeChildHistory : undefined)), + }), + }) + + let releaseHeld!: () => void + const heldGate = new Promise((resolve) => { + releaseHeld = resolve + }) + const held = ClineProvider.prototype["runDelegationTransition"].call(provider, "parent-1", () => heldGate) + + // An external eviction without a transition owner must NOT run the + // interruption under the held lock: it waits for ordinary serialization. + const external = ClineProvider.prototype.evictCurrentTask.call(provider) + let settled = false + void external.then(() => { + settled = true + }) + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + expect(updateTaskHistory).not.toHaveBeenCalled() + expect(settled).toBe(false) + + releaseHeld() + await held + await external + expect(settled).toBe(true) + expect(updateTaskHistory).toHaveBeenCalledTimes(1) + }) + + it("yields child undefined and clears projections for a no-profile handoff", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const updateGlobalState = vi.fn().mockResolvedValue(undefined) + const projectHandoffState = vi.fn().mockResolvedValue(undefined) + + // No current profile anywhere: the prepared profile has no name, which + // is an explicit clear — never a skipped write. + const prepared = createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "unsaved-current", name: undefined, id: undefined }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + persistModeProfileId: undefined, + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: vi.fn().mockResolvedValue(prepared), + // The real bounded queue: generation bookkeeping must be live for the + // projection-result fences under test. + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState, + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockResolvedValue([]), + projectHandoffState, + }, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + + // The child is adopted with an explicitly undefined sticky profile. + expect(child.adoptHandoffExecutionContext).toHaveBeenCalledWith({ + mode: "code", + apiConfigName: undefined, + apiConfiguration: expect.anything(), + }) + // The clear is written, not skipped: the legacy global identity is + // explicitly set to undefined and the durable store clears its identity. + expect(updateGlobalState).toHaveBeenCalledWith("currentApiConfigName", undefined) + expect(projectHandoffState).toHaveBeenCalledWith({ + intent: { kind: "clear" }, + mode: "code", + modeConfigId: undefined, + }) + // The explicit clear stays in force for this child's publication. + await expect(ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-1")).resolves.toBe( + true, + ) + }) + + it("publishes undefined for a stale cleared projection instead of the default fallback", async () => { + const prepared = createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "unsaved-current", name: undefined, id: undefined }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + }) + const provider = makeProviderStub({ + log: vi.fn(), + staleProviderHandoffProjection: { + childTaskId: "child-1", + requestedMode: prepared.requestedMode, + apiConfigName: undefined, + profileIntent: prepared.profile.intent, + apiConfiguration: structuredClone(prepared.apiConfiguration), + generation: 3, + }, + providerProfileMutationSettledGeneration: 0, + }) + + await expect(ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-1")).resolves.toBe( + true, + ) + // A different child is not covered by the stale clear. + await expect( + ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-other"), + ).resolves.toBe(false) + }) + + it("never invokes a queued callback whose caller timed out, even after the earlier write settles", async () => { + vi.useFakeTimers() + try { + const providerEmit = vi.fn() + const provider = makeProviderStub({ log: vi.fn(), emit: providerEmit }) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + // The first operation starts immediately and hangs past its timeout: + // it owns the queue tail the whole time. + const firstWrite = vi.fn(() => firstGate) + // The second operation is queued behind it when the timeout fires. + // A callback like this one that ignores its own signal is exactly + // the case the central admission fence must stop: fn itself would + // have written without ever checking the signal. + const secondWrite = vi.fn(async () => "second") + + const first = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, firstWrite) + const second = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, secondWrite) + const firstOutcome = first.then( + () => "resolved", + (error: unknown) => (error as Error).message, + ) + const secondOutcome = second.then( + () => "resolved", + (error: unknown) => (error as Error).message, + ) + + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + expect(await firstOutcome).toContain("timed out") + expect(await secondOutcome).toContain("timed out") + expect(firstWrite).toHaveBeenCalledTimes(1) + expect(secondWrite).not.toHaveBeenCalled() + + // Releasing the earlier operation admits the queue head: the + // cancelled callback is rejected at admission and fn never runs — + // no storage write and no event can originate from it. + releaseFirst() + await vi.advanceTimersByTimeAsync(0) + expect(secondWrite).not.toHaveBeenCalled() + + const providerState = provider as unknown as { providerProfileMutationSettledGeneration: number } + expect(providerState.providerProfileMutationSettledGeneration).toBe(0) + expect(providerEmit).not.toHaveBeenCalled() + + // The queue itself stays live: a later mutation is admitted. + const thirdWrite = vi.fn(async () => "third") + const third = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, thirdWrite) + await vi.advanceTimersByTimeAsync(1) + expect(thirdWrite).toHaveBeenCalledTimes(1) + await expect(third).resolves.toBe("third") + } finally { + vi.useRealTimers() + } + }) + + it("provider disposal cancels queued-not-started mutations and rejects new work", async () => { + vi.useFakeTimers() + try { + const provider = makeProviderStub({ log: vi.fn() }) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const startedWrite = vi.fn(() => firstGate) + const queuedWrite = vi.fn(async () => "queued") + + const started = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, startedWrite) + const queued = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, queuedWrite) + const startedOutcome = started.then( + () => "resolved", + (error: unknown) => (error as Error).message, + ) + const queuedOutcome = queued.then( + () => "resolved", + (error: unknown) => (error as Error).message, + ) + + // Let the queue admit (start) the first write and keep the second + // queued behind it. + await vi.advanceTimersByTimeAsync(0) + + // Dispose while one write is started-and-hung and one is queued. + // Disposal returns only at the bounded drain deadline. + const disposed = ClineProvider.prototype["disposeProviderProfileMutationQueue"].call(provider) + await vi.advanceTimersByTimeAsync(5001) + await disposed + + expect(startedWrite).toHaveBeenCalledTimes(1) + // The queued callback was cancelled at admission and never starts — + // even after the started write settles. + expect(queuedWrite).not.toHaveBeenCalled() + releaseFirst() + await vi.advanceTimersByTimeAsync(0) + expect(queuedWrite).not.toHaveBeenCalled() + expect(await queuedOutcome).toContain("cancelled before admission") + await expect(startedOutcome).resolves.toBe("resolved") + + // No new work is admitted after disposal. + await expect( + ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, async () => "late"), + ).rejects.toThrow("provider is disposed") + } finally { + vi.useRealTimers() + } + }) + + it("provider disposal bounds the drain of a started write and keeps late completions inert", async () => { + vi.useFakeTimers() + try { + const providerEmit = vi.fn() + const provider = makeProviderStub({ + log: vi.fn(), + emit: providerEmit, + staleProviderHandoffProjection: { + childTaskId: "child-1", + requestedMode: "code", + apiConfigName: "profile-1", + profileIntent: { kind: "set", name: "profile-1" }, + apiConfiguration: {}, + generation: 1, + }, + }) + let releaseWrite!: () => void + const writeGate = new Promise((resolve) => { + releaseWrite = resolve + }) + const write = vi.fn(() => writeGate) + const op = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, write) + const outcome = op.then( + () => "resolved", + (error: unknown) => (error as Error).message, + ) + + // Let the queue admit (start) the write. + await vi.advanceTimersByTimeAsync(0) + + // Dispose while the write is started and hung: disposal must return + // at the bounded deadline instead of awaiting the write forever. + const disposed = ClineProvider.prototype["disposeProviderProfileMutationQueue"].call(provider) + await vi.advanceTimersByTimeAsync(5001) + await disposed + expect(write).toHaveBeenCalledTimes(1) + + // The write settles after disposal: its completion is inert — no + // marker supersession, no settled-generation advance, no events. + releaseWrite() + await vi.advanceTimersByTimeAsync(0) + await expect(outcome).resolves.toBe("resolved") + const providerState = provider as unknown as { + providerProfileMutationSettledGeneration: number + staleProviderHandoffProjection?: { requestedMode: string } + } + expect(providerState.providerProfileMutationSettledGeneration).toBe(0) + expect(providerState.staleProviderHandoffProjection).toMatchObject({ requestedMode: "code" }) + expect(providerEmit).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it("reconstructs an explicit clear from durable manager state after a reload", async () => { + // After a reload the in-memory sets are empty; the durable profile + // store identity was cleared and the resumed child still carries no + // sticky profile, so the clear is reconstructed for publication. + const resumedChild = { taskId: "child-1", taskApiConfigName: undefined } + const provider = makeProviderStub({ + log: vi.fn(), + getCurrentTask: vi.fn(() => resumedChild), + providerSettingsManager: { getCurrentProfileName: vi.fn().mockResolvedValue(undefined) }, + }) + await expect(ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-1")).resolves.toBe( + true, + ) + + // A durable identity means no explicit clear: the ordinary default + // fallback is unchanged (including fresh installs). + const withIdentity = makeProviderStub({ + log: vi.fn(), + getCurrentTask: vi.fn(() => resumedChild), + providerSettingsManager: { getCurrentProfileName: vi.fn().mockResolvedValue("default") }, + }) + await expect( + ClineProvider.prototype["isExplicitProfileClearInForce"].call(withIdentity, "child-1"), + ).resolves.toBe(false) + + // A child that later gained a sticky profile is no longer cleared. + const profiledChild = { taskId: "child-1", taskApiConfigName: "chosen" } + const withSticky = makeProviderStub({ + log: vi.fn(), + getCurrentTask: vi.fn(() => profiledChild), + providerSettingsManager: { getCurrentProfileName: vi.fn().mockResolvedValue(undefined) }, + }) + await expect( + ClineProvider.prototype["isExplicitProfileClearInForce"].call(withSticky, "child-1"), + ).resolves.toBe(false) + + // A task that is no longer current is never covered by the fallback. + const staleCheck = makeProviderStub({ + log: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "child-other", taskApiConfigName: undefined })), + providerSettingsManager: { getCurrentProfileName: vi.fn().mockResolvedValue(undefined) }, + }) + await expect( + ClineProvider.prototype["isExplicitProfileClearInForce"].call(staleCheck, "child-1"), + ).resolves.toBe(false) + }) + + it("cleans a removed child's explicit-clear markers when it leaves the stack", async () => { + const removed = { + taskId: "child-1", + instanceId: "instance-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + const provider = makeProviderStub({ + log: vi.fn(), + taskEventListeners: new WeakMap(), + taskRegistry: { + length: 1, + current: removed, + remove: vi.fn(() => removed), + }, + staleProviderHandoffProjection: { + childTaskId: "child-1", + requestedMode: "code", + apiConfigName: undefined, + profileIntent: { kind: "clear" }, + apiConfiguration: {}, + generation: 2, + }, + }) + const providerState = provider as unknown as { + explicitProfileClearChildIds: Set + staleProviderHandoffProjection?: { childTaskId: string } + } + providerState.explicitProfileClearChildIds.add("child-1") + providerState.explicitProfileClearChildIds.add("child-other") + + await ClineProvider.prototype.removeClineFromStack.call(provider) + + // Only the removed child's markers are cleaned; an unrelated child's + // explicit-clear state is untouched. + expect(providerState.explicitProfileClearChildIds.has("child-1")).toBe(false) + expect(providerState.explicitProfileClearChildIds.has("child-other")).toBe(true) + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + }) + + it("invalidateProviderHandoffProjectionState drops only the named child's markers", () => { + const provider = makeProviderStub({ log: vi.fn() }) + const providerState = provider as unknown as { + explicitProfileClearChildIds: Set + staleProviderHandoffProjection?: { + childTaskId: string + requestedMode?: string + apiConfigName?: string + profileIntent?: unknown + apiConfiguration?: unknown + generation?: number + } + } + providerState.explicitProfileClearChildIds.add("child-1") + providerState.staleProviderHandoffProjection = { + childTaskId: "child-1", + requestedMode: "code", + apiConfigName: undefined, + profileIntent: { kind: "clear" }, + apiConfiguration: {}, + generation: 2, + } + + ClineProvider.prototype["invalidateProviderHandoffProjectionState"].call(provider, "child-1") + expect(providerState.explicitProfileClearChildIds.has("child-1")).toBe(false) + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + + // A marker belonging to a different child is left alone. + providerState.staleProviderHandoffProjection = { + childTaskId: "child-other", + requestedMode: "code", + apiConfigName: undefined, + profileIntent: { kind: "clear" }, + apiConfiguration: {}, + generation: 3, + } + ClineProvider.prototype["invalidateProviderHandoffProjectionState"].call(provider, "child-1") + expect(providerState.staleProviderHandoffProjection).toMatchObject({ childTaskId: "child-other" }) + }) + + it("keeps a projection that settles after the child left the provider inert (no stale/clear resurrection)", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + const log = vi.fn() + + // The explicit no-profile handoff makes any late-failure resurrection + // visible twice: as a re-stamped stale marker AND as a re-added + // explicit-clear child id. + const prepared = createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "unsaved-current", name: undefined, id: undefined }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + persistModeProfileId: undefined, + }) + + // The first legacy write hangs until the test releases it, then fails: + // the projection settles deferred, after the child already left. + let releaseWrite!: () => void + const writeGate = new Promise((resolve) => { + releaseWrite = resolve + }) + const updateGlobalState = vi.fn((_key: string, _value: unknown) => + writeGate.then(() => { + throw new Error("global write failed after child removal") + }), + ) + + // Removal double used by the real removeClineFromStack call below. + const removedChild = { + taskId: "child-1", + instanceId: "instance-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + let currentTask: unknown = parentTask + const getCurrentTask = vi.fn(() => currentTask) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(prepared), + // Real bounded queue and real projection: the deferred failure path + // is exactly what the relevance fence must gate. + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState, + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockResolvedValue([]), + projectHandoffState: vi.fn().mockResolvedValue(undefined), + // Durable identity for the reopened-task check below: a store + // identity means no explicit clear. + getCurrentProfileName: vi.fn().mockResolvedValue("default"), + }, + log, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + // Registry consulted only by the real removeClineFromStack call. + taskEventListeners: new WeakMap(), + taskRegistry: { + length: 1, + current: removedChild, + remove: vi.fn(() => removedChild), + }, + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + // Let the queue admit the projection: it starts and hangs on the + // first legacy write. + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + expect(updateGlobalState).toHaveBeenCalledWith("mode", "code") + + // The child leaves the provider while the projection is in flight: + // removal drops the projection-target registration BEFORE awaiting + // the child's abort. + await ClineProvider.prototype.removeClineFromStack.call(provider) + expect(removedChild.abortTask).toHaveBeenCalledTimes(1) + + // Settle the projection failure. The settlement arrives after the + // child's departure, so it must be inert: no stale marker, no + // explicit-clear resurrection, no event. + releaseWrite() + const completion = await ( + provider as unknown as { + providerHandoffProjectionCompletion?: Promise + } + ).providerHandoffProjectionCompletion + expect(completion).toMatchObject({ ok: false, boundary: "context-proxy" }) + + const providerState = provider as unknown as { + staleProviderHandoffProjection?: unknown + explicitProfileClearChildIds: Set + } + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + expect(providerState.explicitProfileClearChildIds.has("child-1")).toBe(false) + expect(providerEmit).not.toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "code") + const logged = log.mock.calls.map((call) => call.join(" ")).join("\n") + expect(logged).not.toContain("Post-commit handoff projection") + + // Reopening the same history task must not overlay the old context: + // with the fence holding, publication takes the ordinary path. + currentTask = { taskId: "child-1", taskApiConfigName: undefined } + await expect(ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-1")).resolves.toBe( + false, + ) + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + }) + + it("keeps an admitted projection's failure authoritative when a newer mutation only enqueues and times out before admission", async () => { + vi.useFakeTimers() + try { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + const log = vi.fn() + + // The projection's first legacy write hangs on a gate: the + // projection is admitted and in flight while the test enqueues an + // unrelated mutation behind it. + let releaseWrite!: () => void + const writeGate = new Promise((resolve) => { + releaseWrite = resolve + }) + const updateGlobalState = vi.fn(() => writeGate) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState, + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockRejectedValue(new Error("listConfig failed")), + projectHandoffState: vi.fn().mockResolvedValue(undefined), + }, + log, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + }) + + const delegation = ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await expect(delegation).resolves.toBe(child) + // Let the queue admit the projection: it binds its admitted + // generation and hangs on the mode write. + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + expect(updateGlobalState).toHaveBeenCalledWith("mode", "code") + const registration = ( + provider as unknown as { + providerHandoffProjectionTargets?: Map + } + ).providerHandoffProjectionTargets?.get("child-1") + expect(registration).toMatchObject({ token: expect.any(Number), admittedGeneration: 1 }) + + // An unrelated mutation is ENQUEUED while the admitted projection + // is in flight. Binding generations at admission (not enqueue) + // means this reservation must not fence the in-flight projection. + const unrelatedWrite = vi.fn(async () => "unrelated") + const unrelated = ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, unrelatedWrite) + const unrelatedOutcome = unrelated.then( + () => "resolved", + (error: unknown) => (error as Error).message, + ) + + // Both caller timeouts fire: the unrelated mutation is cancelled + // BEFORE admission (zero writes, no generation consumed), and the + // started projection's caller is released while its hung write + // keeps the queue tail owned. + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + expect(await unrelatedOutcome).toContain("timed out") + expect(unrelatedWrite).not.toHaveBeenCalled() + + // The admitted projection's abandonment still counts: its exact + // token/generation is registered and no newer mutation was ever + // admitted, so the stale marker IS stamped — the child-local + // context stays authoritative for publication. + const providerState = provider as unknown as { + staleProviderHandoffProjection?: { requestedMode: string; generation: number | undefined } + providerProfileMutationSettledGeneration: number + } + expect(providerState.staleProviderHandoffProjection).toMatchObject({ + requestedMode: "code", + generation: 1, + }) + // A zero-write timeout never supersedes anything: no mutation + // settled successfully, so the marker is not fenced off. + expect(providerState.providerProfileMutationSettledGeneration).toBe(0) + + // The hung write settles; the aborted projection's late completion + // is inert — the stamped marker survives and no mode change fires. + releaseWrite() + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + await vi.advanceTimersByTimeAsync(0) + expect(providerState.staleProviderHandoffProjection).toMatchObject({ requestedMode: "code" }) + expect(providerEmit).not.toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "code") + } finally { + vi.useRealTimers() + } + }) + + it("keeps an old pre-admission projection inert when its task ID is reused by a newer registration", async () => { + vi.useFakeTimers() + try { + const providerEmit = vi.fn() + const log = vi.fn() + const provider = makeProviderStub({ + emit: providerEmit, + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState: vi.fn().mockResolvedValue(undefined), + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockResolvedValue([]), + projectHandoffState: vi.fn().mockResolvedValue(undefined), + }, + log, + getCurrentTask: vi.fn(() => undefined), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + }) + + // Block the queue tail with a started hung write so no queued + // projection can be admitted yet. Its own caller timeout fires by + // design (the started write keeps the tail); handle the caller + // rejection so it cannot surface as an unhandled rejection. + let releaseBlocker!: () => void + const blockerGate = new Promise((resolve) => { + releaseBlocker = resolve + }) + const blockerCaller = ClineProvider.prototype["enqueueProviderProfileMutation"].call( + provider, + () => blockerGate, + ) + blockerCaller.catch(() => {}) + + // Old projection for child-1 carrying a distinct OLD context, + // registered and queued but never admitted. + const oldPrepared = createPreparedProviderHandoffContext({ + requestedMode: "architect", + profile: { source: "unsaved-current", name: "old-profile", id: "old-profile-id" }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + persistModeProfileId: "old-profile-id", + }) + const project = ClineProvider.prototype["projectPreparedProviderHandoffState"] + const oldProjection = project.call(provider, oldPrepared, "child-1") + + // Advance partway, then simulate the old projection's child + // leaving the provider and the SAME task ID being reused by a NEW + // projection: the registry now carries a different token. + await vi.advanceTimersByTimeAsync(10_000) + ClineProvider.prototype["invalidateProviderHandoffProjectionState"].call(provider, "child-1") + const newProjection = project.call(provider, makePreparedHandoff(), "child-1") + + // The old projection's caller timeout (registered at t≈0) fires + // while it is still queued: it was never admitted and its token no + // longer matches the registration, so the abandonment is entirely + // inert. The advance stops short of the NEW projection's own + // timeout window (it was enqueued 10s in). + await vi.advanceTimersByTimeAsync(20_500) + await expect(oldProjection).resolves.toMatchObject({ ok: false, boundary: "queue" }) + const providerState = provider as unknown as { + staleProviderHandoffProjection?: { requestedMode: string } + explicitProfileClearChildIds: Set + } + // The old context is never stamped — no marker at all. + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + expect(providerState.explicitProfileClearChildIds.size).toBe(0) + + // The new projection is still inside its own timeout window: it + // runs once the blocker settles and is fully authoritative. + releaseBlocker() + await expect(newProjection).resolves.toMatchObject({ ok: true }) + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "code") + const registration = ( + provider as unknown as { + providerHandoffProjectionTargets?: Map + } + ).providerHandoffProjectionTargets?.get("child-1") + expect(registration?.admittedGeneration).toBeDefined() + } finally { + vi.useRealTimers() + } + }) + + it("a current normal projection clears a stale marker, binds its exact admitted generation, and emits", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + + // A stale marker left by an older failed projection for the same + // child; the new (successful) projection must clear it. + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + enqueueProviderProfileMutation: ClineProvider.prototype["enqueueProviderProfileMutation"], + updateGlobalState: vi.fn().mockResolvedValue(undefined), + contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + listConfig: vi.fn().mockResolvedValue([]), + projectHandoffState: vi.fn().mockResolvedValue(undefined), + }, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore: makeStoreStub(), + staleProviderHandoffProjection: { + childTaskId: "child-1", + requestedMode: "architect", + apiConfigName: "old-profile", + profileIntent: { kind: "set", name: "old-profile" }, + apiConfiguration: {}, + generation: 1, + }, + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await (provider as unknown as { providerHandoffProjectionCompletion?: Promise }) + .providerHandoffProjectionCompletion + + const providerState = provider as unknown as { + staleProviderHandoffProjection?: unknown + providerHandoffProjectionTargets?: Map + } + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "code") + // Admission bound the exact generation into the registration — the + // relevance fence can now require it. + expect(providerState.providerHandoffProjectionTargets?.get("child-1")).toEqual({ + token: expect.any(Number), + admittedGeneration: 1, + }) + }) + + it("deleteTaskFromState invalidates projection state synchronously, before the durable delete and any post", async () => { + const targets = new Map([ + ["child-1", { token: 41, admittedGeneration: 2 }], + ]) + const registrationPresentDuringDelete: boolean[] = [] + const provider = makeProviderStub({ + postStateToWebview: vi.fn().mockResolvedValue(undefined), + recentTasksCache: undefined, + taskHistoryStore: { + delete: vi.fn(async () => { + // Evaluated at the durable-delete boundary: the in-memory + // invalidation must already have happened synchronously, + // before this await even started. + registrationPresentDuringDelete.push(targets.has("child-1")) + }), + }, + }) + const providerState = provider as unknown as { + providerHandoffProjectionTargets?: Map + explicitProfileClearChildIds: Set + staleProviderHandoffProjection?: { childTaskId: string } + } + providerState.providerHandoffProjectionTargets = targets + providerState.explicitProfileClearChildIds.add("child-1") + providerState.staleProviderHandoffProjection = { childTaskId: "child-1" } + // Sanity: the registered identity (exact token) is relevant before the + // delete. + expect(ClineProvider.prototype["isProviderHandoffProjectionStillRelevant"].call(provider, "child-1", 41)).toBe( + true, + ) + + await ClineProvider.prototype.deleteTaskFromState.call(provider, "child-1") + + // Invalidation was synchronous (nothing registered by the time the + // durable delete ran) and stays clean afterwards: a deferred + // projection settlement cannot pass the fence for the deleted task. + expect(registrationPresentDuringDelete).toEqual([false]) + expect(targets.has("child-1")).toBe(false) + expect(providerState.explicitProfileClearChildIds.has("child-1")).toBe(false) + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + expect(ClineProvider.prototype["isProviderHandoffProjectionStillRelevant"].call(provider, "child-1", 41)).toBe( + false, + ) }) }) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 59dde33933..800d48ac68 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -4,21 +4,27 @@ import { type Task } from "../../core/task/Task" type ProviderStubFields = { delegationTransitionLocks?: Map> + delegationTransitionOwners?: Map cancelledDelegationChildIds?: Set + explicitProfileClearChildIds?: Set log?: ReturnType taskHistoryStore?: { get: (id: string) => unknown } taskRegistry?: TaskRegistry clineStack?: Task[] tasks?: Task[] runDelegationTransition?: unknown + markDelegatedChildInterruptedUnlocked?: unknown removeClineFromStack?: unknown evictCurrentTask?: unknown + invalidateProviderHandoffProjectionState?: unknown } type PrivateProviderMethods = { runDelegationTransition: (this: unknown, ...args: unknown[]) => unknown + markDelegatedChildInterruptedUnlocked: (this: unknown, ...args: unknown[]) => unknown removeClineFromStack: (this: unknown, ...args: unknown[]) => unknown evictCurrentTask: (this: unknown, ...args: unknown[]) => unknown + invalidateProviderHandoffProjectionState: (this: unknown, ...args: unknown[]) => unknown } /** @@ -35,7 +41,9 @@ export function makeProviderStub(stub: T): ClineProvider { const s = stub as T & ProviderStubFields const proto = ClineProvider.prototype as unknown as PrivateProviderMethods s.delegationTransitionLocks ??= new Map() + s.delegationTransitionOwners ??= new Map() s.cancelledDelegationChildIds ??= new Set() + s.explicitProfileClearChildIds ??= new Set() s.log ??= vi.fn() s.taskHistoryStore ??= { get: () => undefined } @@ -49,7 +57,11 @@ export function makeProviderStub(stub: T): ClineProvider { delete s.clineStack s.runDelegationTransition ??= proto.runDelegationTransition.bind(s) + s.markDelegatedChildInterruptedUnlocked ??= proto.markDelegatedChildInterruptedUnlocked.bind(s) s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) + s.invalidateProviderHandoffProjectionState ??= ( + ClineProvider.prototype as unknown as PrivateProviderMethods + ).invalidateProviderHandoffProjectionState.bind(s) return s as unknown as ClineProvider } diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index eed8127b82..2fea1a3183 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -299,10 +299,82 @@ describe("History resume delegation - parent metadata transitions", () => { status: "active", completedByChildId: "child-1", }), - { startTask: false }, + { startTask: false, transitionOwner: expect.anything() }, ) }) + it("reopenParentFromDelegation invalidates the child's projection state at the durable commit boundary so a later reconstruction failure cannot retain it", async () => { + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + ts: Date.now(), + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const childHistoryItem = { + id: "child-1", + status: "active", + pendingAction: { + kind: "finish_subtask", + actionId: "finish-action", + approvalText: JSON.stringify({ tool: "finishTask" }), + parentTaskId: "parent-1", + result: "Done", + }, + } + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), + getCurrentTask: vi.fn(() => ({ taskId: "child-1" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + // Parent reconstruction fails after the child is already durably + // completed: the terminal invalidation must still have happened. + createTaskWithHistoryItem: vi.fn().mockRejectedValue(new Error("parent reconstruction failed")), + taskHistoryStore, + isViewLaunched: false, + emit: vi.fn(), + log: vi.fn(), + }) + const providerState = provider as unknown as { + providerHandoffProjectionTargets?: Map + explicitProfileClearChildIds: Set + staleProviderHandoffProjection?: { childTaskId: string } + } + providerState.providerHandoffProjectionTargets = new Map([["child-1", { token: 7, admittedGeneration: 3 }]]) + providerState.explicitProfileClearChildIds.add("child-1") + providerState.staleProviderHandoffProjection = { childTaskId: "child-1" } + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "Done", + pendingActionId: "finish-action", + }), + ).rejects.toThrow("parent reconstruction failed") + + // The durable commit happened (child completed), so the child's + // projection-target registration, explicit-clear bookkeeping, and + // stale marker are gone even though the parent reopen failed. + expect(taskHistoryStore.atomicUpdatePair).toHaveBeenCalledTimes(1) + expect(providerState.providerHandoffProjectionTargets?.has("child-1")).toBe(false) + expect(providerState.explicitProfileClearChildIds.has("child-1")).toBe(false) + expect(providerState.staleProviderHandoffProjection).toBeUndefined() + // A deferred projection settlement presenting the old identity is + // inert: exact token/generation no longer matches any registration. + expect( + ( + ClineProvider.prototype as unknown as { + isProviderHandoffProjectionStillRelevant: (this: unknown, ...args: unknown[]) => boolean + } + ).isProviderHandoffProjectionStillRelevant.call(provider, "child-1", 7, 3), + ).toBe(false) + }) + it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => { const parentItem = { id: "p1", @@ -947,7 +1019,7 @@ describe("History resume delegation - parent metadata transitions", () => { status: "active", completedByChildId: "child-rpd02", }), - { startTask: false }, + { startTask: false, transitionOwner: expect.anything() }, ) expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) }) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 51f79cff35..12f3648e32 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -19,6 +19,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" import { buildApiHandler } from "../../api" import { downgradeLegacyRooConfig } from "./routerRemoval" +import type { ProviderHandoffProfileIntent } from "../task-persistence/providerHandoff" // Type-safe model migrations mapping type ModelMigrations = { @@ -33,8 +34,28 @@ export interface SyncCloudProfilesResult { activeProfileId: string } +/** + * Read-only snapshot of the durable profile store state needed to prepare a + * provider handoff. `savedProfile` carries the full profile (including + * provider secret fields) for the requested mode's saved mapping, if any. + */ +export interface ProviderProfileSnapshot { + currentApiConfigName: string | undefined + entries: ProviderSettingsEntry[] + modeApiConfigId: string | undefined + savedProfile: (ProviderSettingsWithId & { name: string }) | undefined +} + +/** + * The current profile identity is optional so an explicit handoff `clear` + * intent can durably remove it. Backward compatibility: existing stores that + * carry an identity parse unchanged; fresh installs are explicitly seeded with + * the "default" identity in {@link defaultProviderProfiles}; a store whose + * identity was cleared parses as `undefined` and stays cleared on reload + * (initialization only seeds the defaults when the store file is absent). + */ export const providerProfilesSchema = z.object({ - currentApiConfigName: z.string(), + currentApiConfigName: z.string().optional(), apiConfigs: z.record(z.string(), providerSettingsWithIdSchema), modeApiConfigs: z.record(z.string(), z.string()).optional(), cloudProfileIds: z.array(z.string()).optional(), @@ -112,10 +133,12 @@ export class ProviderSettingsManager { // Migrate existing installs to have per-mode API config map if (!providerProfiles.modeApiConfigs) { - // Use the currently selected config for all modes initially + // Use the currently selected config for all modes initially. + // The current identity is optional (explicit clear); fall + // back to the first profile, then the generated default id. const currentName = providerProfiles.currentApiConfigName const seedId = - providerProfiles.apiConfigs[currentName]?.id ?? + (currentName !== undefined ? providerProfiles.apiConfigs[currentName]?.id : undefined) ?? Object.values(providerProfiles.apiConfigs)[0]?.id ?? this.defaultConfigId providerProfiles.modeApiConfigs = Object.fromEntries(modes.map((m) => [m.slug, seedId])) @@ -503,6 +526,115 @@ export class ProviderSettingsManager { } } + /** + * Read-only, single-lock snapshot used by provider handoff preparation. + * + * Reads the durable current-profile identity, the requested mode's saved + * mapping, the profile metadata list, and the matching full profile + * (including provider secret fields) under one lock acquisition with a + * single store load. Performs no writes, so a failing preparation cannot + * mutate the profile store. + */ + public async snapshotForHandoff(mode: Mode): Promise { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + + const entries: ProviderSettingsEntry[] = Object.entries(providerProfiles.apiConfigs).map( + ([name, apiConfig]) => ({ + name, + id: apiConfig.id || "", + apiProvider: apiConfig.apiProvider, + modelId: this.cleanModelId(getModelId(apiConfig)), + }), + ) + + const modeApiConfigId = providerProfiles.modeApiConfigs?.[mode] + + let savedProfile: (ProviderSettingsWithId & { name: string }) | undefined + if (modeApiConfigId) { + const savedEntry = Object.entries(providerProfiles.apiConfigs).find( + ([_, apiConfig]) => apiConfig.id === modeApiConfigId, + ) + + if (savedEntry) { + // Clone so the snapshot never aliases the live store data. + savedProfile = structuredClone({ name: savedEntry[0], ...savedEntry[1] }) + } + } + + return { + currentApiConfigName: providerProfiles.currentApiConfigName, + entries, + modeApiConfigId, + savedProfile, + } + }) + } catch (error) { + throw new Error(`Failed to snapshot provider profiles for handoff: ${error}`) + } + } + + /** + * Post-commit handoff projection driven by an explicit profile intent: + * + * - `set`: durably persist the given profile identity (and the requested + * mode's mapping when provided) in one locked load/store cycle. + * - `preserve`: perform no store write at all — the profile identity is + * pinned across modes and must never be rewritten by the handoff. + * - `clear`: durably remove the current-profile identity by writing + * `undefined` (never by skipping the write). + * + * This is a legacy projection — the delegating child's task-local context + * is already authoritative when this runs. + */ + public async projectHandoffState(params: { + intent: ProviderHandoffProfileIntent + mode?: Mode + modeConfigId?: string + }) { + try { + return await this.lock(async () => { + if (params.intent.kind === "preserve") { + // Explicit no-op: the pinned identity is left untouched. + return + } + + const providerProfiles = await this.load() + let isDirty = false + + if (params.intent.kind === "set") { + const name = params.intent.name + if (providerProfiles.currentApiConfigName !== name) { + providerProfiles.currentApiConfigName = name + isDirty = true + } + } else if (providerProfiles.currentApiConfigName !== undefined) { + // Explicit clear: write the absence durably instead of skipping. + providerProfiles.currentApiConfigName = undefined + isDirty = true + } + + if (params.mode && params.modeConfigId) { + if (!providerProfiles.modeApiConfigs) { + providerProfiles.modeApiConfigs = {} + } + + if (providerProfiles.modeApiConfigs[params.mode] !== params.modeConfigId) { + providerProfiles.modeApiConfigs[params.mode] = params.modeConfigId + isDirty = true + } + } + + if (isDirty) { + await this.store(providerProfiles) + } + }) + } catch (error) { + throw new Error(`Failed to project provider handoff state: ${error}`) + } + } + /** * Set the API config for a specific mode. */ @@ -537,6 +669,20 @@ export class ProviderSettingsManager { } } + /** + * Durable current-profile identity, or `undefined` when an explicit clear + * removed it. One locked store read; used to reconstruct an explicit + * profile clear after a provider reload (the in-memory per-child markers + * do not survive a reload, the durable store identity does). + */ + public async getCurrentProfileName(): Promise { + try { + return await this.lock(async () => (await this.load()).currentApiConfigName) + } catch (error) { + throw new Error(`Failed to get current profile name: ${error}`) + } + } + public async export() { try { return await this.lock(async () => { diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index be0cbfec92..6592b2f79b 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -1510,4 +1510,262 @@ describe("ProviderSettingsManager", () => { expect(result.activeProfileId).toBe("local-id") }) }) + + describe("snapshotForHandoff", () => { + /** Complete migrations so the constructor's initialize() never writes defaults. */ + const fullyMigrated = { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + routerProviderMigrated: true, + } + + it("performs one locked load, zero secret stores, and preserves the full saved profile", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "current-profile", + apiConfigs: { + "current-profile": { id: "current-id", apiProvider: providerIdentifiers.openai }, + "ask-profile": { + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "sk-sentinel-123456", + }, + }, + modeApiConfigs: { ask: "ask-id" }, + migrations: fullyMigrated, + }), + ) + + mockSecrets.get.mockClear() + const snapshot = await providerSettingsManager.snapshotForHandoff("ask") + + // One lock acquisition performs exactly one store load. + expect(mockSecrets.get).toHaveBeenCalledTimes(1) + // Read-only: no secret store write happened during the snapshot. + expect(mockSecrets.store).not.toHaveBeenCalled() + + expect(snapshot.currentApiConfigName).toBe("current-profile") + expect(snapshot.modeApiConfigId).toBe("ask-id") + expect(snapshot.entries.map(({ name, id, apiProvider }) => ({ name, id, apiProvider }))).toEqual([ + { name: "current-profile", id: "current-id", apiProvider: providerIdentifiers.openai }, + { name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }, + ]) + // The saved profile includes the full data with the sentinel provider secret. + expect(snapshot.savedProfile).toMatchObject({ + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "sk-sentinel-123456", + }) + + // The snapshot must not alias the durable store data. + if (!snapshot.savedProfile) { + throw new Error("expected a saved profile snapshot") + } + snapshot.savedProfile.openRouterApiKey = "mutated" + mockSecrets.get.mockClear() + const reread = await providerSettingsManager.snapshotForHandoff("ask") + expect(reread.savedProfile?.openRouterApiKey).toBe("sk-sentinel-123456") + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + + it("returns no saved profile when the mode has no saved mapping", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "current-profile", + apiConfigs: { "current-profile": { id: "current-id", apiProvider: providerIdentifiers.openai } }, + modeApiConfigs: {}, + migrations: fullyMigrated, + }), + ) + + const snapshot = await providerSettingsManager.snapshotForHandoff("architect") + + expect(snapshot.modeApiConfigId).toBeUndefined() + expect(snapshot.savedProfile).toBeUndefined() + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + + it("propagates load failures without mutating the store", async () => { + mockSecrets.get.mockResolvedValue("not-json{{{") + + await expect(providerSettingsManager.snapshotForHandoff("ask")).rejects.toThrow( + "Failed to snapshot provider profiles for handoff", + ) + expect(mockSecrets.store).not.toHaveBeenCalled() + expect(mockSecrets.delete).not.toHaveBeenCalled() + }) + }) + + describe("projectHandoffState", () => { + it("persists the current profile name and mode mapping in one locked store write", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "old-current", + apiConfigs: { + "old-current": { id: "old-id", apiProvider: providerIdentifiers.openai }, + "child-profile": { id: "child-id", apiProvider: providerIdentifiers.openrouter }, + }, + modeApiConfigs: { code: "old-id" }, + migrations: { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + routerProviderMigrated: true, + }, + }), + ) + mockSecrets.get.mockClear() + + await providerSettingsManager.projectHandoffState({ + intent: { kind: "set", name: "child-profile" }, + mode: "ask", + modeConfigId: "child-id", + }) + + // One locked load + one locked store write. + expect(mockSecrets.get).toHaveBeenCalledTimes(1) + expect(mockSecrets.store).toHaveBeenCalledTimes(1) + + const stored = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(stored.currentApiConfigName).toBe("child-profile") + expect(stored.modeApiConfigs.ask).toBe("child-id") + expect(stored.modeApiConfigs.code).toBe("old-id") + }) + + it("skips the store write when the projection is already durable", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "child-profile", + apiConfigs: { "child-profile": { id: "child-id", apiProvider: providerIdentifiers.openrouter } }, + modeApiConfigs: { ask: "child-id" }, + }), + ) + mockSecrets.store.mockClear() + + await providerSettingsManager.projectHandoffState({ + intent: { kind: "set", name: "child-profile" }, + mode: "ask", + modeConfigId: "child-id", + }) + + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + + it("propagates failures without a partial write", async () => { + mockSecrets.get.mockResolvedValue(null) + mockSecrets.store.mockRejectedValue(new Error("disk full")) + + await expect( + providerSettingsManager.projectHandoffState({ intent: { kind: "set", name: "child-profile" } }), + ).rejects.toThrow("Failed to project provider handoff state") + }) + + it("clear writes the explicit absence of a current profile identity and survives reload", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "old-current", + apiConfigs: { "old-current": { id: "old-id", apiProvider: providerIdentifiers.openai } }, + modeApiConfigs: {}, + migrations: { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + routerProviderMigrated: true, + }, + }), + ) + mockSecrets.get.mockClear() + mockSecrets.store.mockClear() + + await providerSettingsManager.projectHandoffState({ intent: { kind: "clear" } }) + + expect(mockSecrets.store).toHaveBeenCalledTimes(1) + const stored = JSON.parse(mockSecrets.store.mock.calls[0][1]) + // The identity is durably removed, not skipped. + expect(stored.currentApiConfigName).toBeUndefined() + + // Reload: the cleared store parses back with no current identity. + mockSecrets.get.mockResolvedValue(mockSecrets.store.mock.calls[0][1]) + const reloaded = await providerSettingsManager.snapshotForHandoff("code") + expect(reloaded.currentApiConfigName).toBeUndefined() + }) + + it("preserve performs no store write at all", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "pinned-profile", + apiConfigs: { "pinned-profile": { id: "pinned-id", apiProvider: providerIdentifiers.openai } }, + modeApiConfigs: {}, + migrations: { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + routerProviderMigrated: true, + }, + }), + ) + mockSecrets.get.mockClear() + mockSecrets.store.mockClear() + + await providerSettingsManager.projectHandoffState({ + intent: { kind: "preserve" }, + mode: "ask", + modeConfigId: "pinned-id", + }) + + // Preserve touches neither the identity nor anything else: zero + // store loads and zero writes even when a mode mapping is offered. + expect(mockSecrets.get).not.toHaveBeenCalled() + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + }) + + describe("getCurrentProfileName", () => { + it("returns the durable identity and undefined after an explicit clear", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "current-profile", + apiConfigs: { "current-profile": { id: "current-id" } }, + modeApiConfigs: {}, + migrations: { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + routerProviderMigrated: true, + }, + }), + ) + await expect(providerSettingsManager.getCurrentProfileName()).resolves.toBe("current-profile") + + // An explicit handoff clear durably removed the identity: reload + // the projected store content and reconstruct the clear as + // undefined. + await providerSettingsManager.projectHandoffState({ intent: { kind: "clear" } }) + const stored = mockSecrets.store.mock.calls.at(-1)?.[1] as string + mockSecrets.get.mockResolvedValue(stored) + await expect(providerSettingsManager.getCurrentProfileName()).resolves.toBeUndefined() + }) + + it("seeds the default identity for a fresh install, keeping the legacy fallback", async () => { + mockSecrets.get.mockResolvedValue(null) + // A fresh install's store carries the seeded "default" identity, so + // the publication fallback for ordinary (non-cleared) state is + // unchanged. + await expect(providerSettingsManager.getCurrentProfileName()).resolves.toBe("default") + }) + }) }) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 6a99adaa7c..672d10f6b0 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -336,8 +336,11 @@ describe("importExport", () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) // Invalid content (missing required fields). + // A missing apiConfigs record is invalid; a missing + // currentApiConfigName is legal (the identity is optional and an + // explicit clear removes it). const mockInvalidContent = JSON.stringify({ - providerProfiles: { apiConfigs: {} }, + providerProfiles: {}, globalSettings: {}, }) @@ -349,7 +352,7 @@ describe("importExport", () => { customModesManager: mockCustomModesManager, }) - expect(result).toEqual({ success: false, error: "[providerProfiles.currentApiConfigName]: Required" }) + expect(result).toEqual({ success: false, error: "[providerProfiles.apiConfigs]: Required" }) expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8") expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() expect(mockContextProxy.setValues).not.toHaveBeenCalled() @@ -410,6 +413,112 @@ describe("importExport", () => { ]) }) + it("preserves an explicit clear from a sentinel-marked export instead of defaulting to the first profile", async () => { + // A cleared export written by exportSettings: no identity key plus + // the explicit `currentApiConfigCleared` sentinel. + const clearedFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigCleared: true, + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, + }, + }) + ;(fs.readFile as Mock).mockResolvedValue(clearedFileContent) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + ]) + + const result = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(true) + // The intentional clear survives the import: no fallback to the + // first profile, and the durable store receives the cleared identity. + expect(mockProviderSettingsManager.import).toHaveBeenCalledWith( + expect.objectContaining({ currentApiConfigName: undefined }), + ) + expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", undefined) + // An intentional clear is not a warning-worthy fallback. + expect(result).toMatchObject({ warnings: undefined }) + }) + + it("keeps the first-profile fallback for a legacy export that merely lacks the identity", async () => { + // Legacy export without the sentinel: absence is ambiguous and the + // historical fallback behavior is preserved. + const legacyFileContent = JSON.stringify({ + providerProfiles: { + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, + }, + }) + ;(fs.readFile as Mock).mockResolvedValue(legacyFileContent) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + ]) + + const result = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(true) + expect(mockProviderSettingsManager.import).toHaveBeenCalledWith( + expect.objectContaining({ currentApiConfigName: "test" }), + ) + expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "test") + expect(result).toMatchObject({ + warnings: [`Profile "undefined" was not available; defaulting to "test".`], + }) + }) + + it("falls back predictably when a named identity is not among the imported profiles", async () => { + const ghostFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "ghost", + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, + }, + }) + ;(fs.readFile as Mock).mockResolvedValue(ghostFileContent) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + ]) + + const result = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(true) + expect(mockProviderSettingsManager.import).toHaveBeenCalledWith( + expect.objectContaining({ currentApiConfigName: "test" }), + ) + expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "test") + expect(result).toMatchObject({ + warnings: [`Profile "ghost" was not available; defaulting to "test".`], + }) + }) + it("should return success: false when file content is not valid JSON", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) const mockInvalidJson = "{ this is not valid JSON }" @@ -1509,6 +1618,46 @@ describe("importExport", () => { }) }) + it("marks a cleared export with the explicit-clear sentinel and writes identified exports unchanged", async () => { + ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ fsPath: "/mock/path/export.json" }) + + // Cleared durable state: the export carries the sentinel so an + // importing client can distinguish the clear from a legacy export. + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: undefined, + apiConfigs: { test: { apiProvider: providerIdentifiers.openai, id: "test-id" } }, + }) + mockContextProxy.export.mockResolvedValue({ mode: "code" }) + + await exportSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/export.json", { + providerProfiles: expect.objectContaining({ currentApiConfigCleared: true }), + globalSettings: { mode: "code" }, + }) + + // An export that still carries an identity is written unchanged. + ;(safeWriteJson as Mock).mockClear() + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "test", + apiConfigs: { test: { apiProvider: providerIdentifiers.openai, id: "test-id" } }, + }) + ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ fsPath: "/mock/path/export2.json" }) + + await exportSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/export2.json", { + providerProfiles: expect.not.objectContaining({ currentApiConfigCleared: true }), + globalSettings: { mode: "code" }, + }) + }) + it("should include globalSettings when allowedMaxRequests is null", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ fsPath: "/mock/path/zoo-code-settings.json", diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 7b3b5aa231..682ae9d374 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -15,7 +15,7 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager" +import { ProviderSettingsManager, providerProfilesSchema, type ProviderProfiles } from "./ProviderSettingsManager" import { ContextProxy } from "./ContextProxy" import { CustomModesManager } from "./CustomModesManager" import { downgradeLegacyRooConfig, ROUTER_REMOVAL_IMPORT_WARNING } from "./routerRemoval" @@ -136,9 +136,15 @@ export async function importSettingsFromPath( filePath: string, { providerSettingsManager, contextProxy, customModesManager }: ImportOptions, ) { - // Use a lenient schema that accepts any apiConfigs, then validate each individually + // Use a lenient schema that accepts any apiConfigs, then validate each individually. + // `currentApiConfigCleared` is the explicit-clear sentinel written by + // exportSettings: JSON cannot distinguish an absent identity key from a + // legacy export that never carried one, so an intentionally cleared + // export marks itself. Older versions strip the unknown key on parse and + // keep their historical fallback behavior. const lenientProviderProfilesSchema = providerProfilesSchema.extend({ apiConfigs: z.record(z.string(), z.any()), + currentApiConfigCleared: z.boolean().optional(), }) const lenientSchema = z.object({ @@ -185,12 +191,23 @@ export async function importSettingsFromPath( } // Determine the currentApiConfigName: + // 0. An export explicitly marked as cleared (`currentApiConfigCleared`) + // preserves its intentional clear — the absent identity is NOT + // normalized to the first profile. // 1. If the imported currentApiConfigName exists in validApiConfigs, use it - // 2. Otherwise, fall back to the first valid imported profile + // 2. Otherwise (invalid named identity, or legacy export without the + // marker whose identity is absent), fall back to the first valid + // imported profile // 3. If no valid profiles were imported, keep the previous currentApiConfigName let currentApiConfigName = rawProviderProfiles.currentApiConfigName const validProfileNames = Object.keys(validApiConfigs) - if (!validApiConfigs[currentApiConfigName]) { + // The sentinel distinguishes an intentional clear from an unavailable + // profile name and from a legacy export that simply lacks the field. + const explicitClear = rawProviderProfiles.currentApiConfigCleared === true + if (explicitClear) { + // Intentional clear: keep the identity absent and durably clear it. + currentApiConfigName = undefined + } else if (currentApiConfigName === undefined || !validApiConfigs[currentApiConfigName]) { if (validProfileNames.length > 0) { currentApiConfigName = validProfileNames[0] warnings.push( @@ -231,7 +248,8 @@ export async function importSettingsFromPath( // Set the current provider. const currentProviderName = providerProfiles.currentApiConfigName - const currentProvider = providerProfiles.apiConfigs[currentProviderName] + const currentProvider = + currentProviderName !== undefined ? providerProfiles.apiConfigs[currentProviderName] : undefined await contextProxy.setValue("currentApiConfigName", currentProviderName) // TODO: It seems like we don't need to have the provider settings in @@ -309,6 +327,22 @@ export const importSettingsFromFile = async ( }) } +/** + * Explicit-clear sentinel for exported provider profiles. An export whose + * current identity is absent is an intentional state (an explicit handoff or + * import clear), but JSON serialization cannot distinguish an absent key from + * a legacy export that never carried the field. Cleared exports therefore + * mark themselves with `currentApiConfigCleared: true` so import can preserve + * the clear instead of normalizing it to the first profile. Exports that + * still carry an identity are written unchanged. + */ +function withExplicitClearMarker(profiles: ProviderProfiles): ProviderProfiles & { currentApiConfigCleared?: true } { + if (profiles.currentApiConfigName !== undefined) { + return profiles + } + return { ...profiles, currentApiConfigCleared: true } +} + export const exportSettings = async ({ providerSettingsManager, contextProxy }: ExportOptions) => { const defaultUri = await resolveDefaultSaveUri(contextProxy, "lastSettingsExportPath", "zoo-code-settings.json", { useWorkspace: false, @@ -343,7 +377,10 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }: const dirname = path.dirname(uri.fsPath) await fs.mkdir(dirname, { recursive: true }) - await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings }) + await safeWriteJson(uri.fsPath, { + providerProfiles: withExplicitClearMarker(providerProfiles), + globalSettings, + }) } catch (e) { console.error("Failed to export settings:", e) // Don't re-throw - the UI will handle showing error messages diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 3d4cc47604..25e29c1c16 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -7,7 +7,12 @@ import deepEqual from "fast-deep-equal" import type { HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" -import { LOCK_STALE_MS, safeWriteJson } from "../../utils/safeWriteJson" +import { + LOCK_STALE_MS, + safeWriteJson, + withAdvisoryFileLock, + ADVISORY_READ_LOCK_RETRIES, +} from "../../utils/safeWriteJson" import { getStorageBasePath } from "../../utils/storage" import { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" @@ -65,6 +70,16 @@ interface DelegationRepairIntent { * dropped. Within a single extension host process, an in-process write * lock serializes mutations. */ +/** + * Discriminated result of a strict fresh read. `missing` means the durable + * record is definitively absent; `error` means it exists but is unreadable, + * unparseable, or incompatible — durability is unknowable, not absent. + */ +export type StrictTaskReadResult = + | { readonly kind: "found"; readonly item: HistoryItem } + | { readonly kind: "missing" } + | { readonly kind: "error"; readonly reason: "read" | "parse" | "incompatible"; readonly error: unknown } + /** * Options for TaskHistoryStore constructor. */ @@ -751,6 +766,95 @@ export class TaskHistoryStore { // ────────────────────────────── Cache invalidation ────────────────────────────── + /** + * Fresh, lock-held read of a task's durable record that distinguishes the + * three outcomes callers must tell apart at the delegation reconciliation + * boundary: + * + * - `found`: the record exists and parsed; the cache is refreshed from it. + * - `missing`: the task file does not exist (definitively absent). + * - `error`: the record exists but could not be read or parsed, or is + * incompatible (no usable `id`). + * + * Unlike `invalidate` — which collapses every read/parse failure into a + * cache delete — this result lets the caller treat "definitively absent" + * differently from "unknowable", which is the difference between a safe + * rollback and a non-destructive degraded abort. + */ + async readFresh(taskId: string): Promise { + return this.withLock(async () => { + const filePath = await this.getTaskFilePath(taskId) + + // Lock order: the store's in-process write lock is already held; + // the advisory per-file lock below is the same `proper-lockfile` + // lock `safeWriteJson` acquires for this exact path (writers take + // the two locks in the same order, so this can wait out an + // in-flight write without deadlocking). Holding it means this read + // can never observe safeWriteJson's backup/commit rename gap or a + // stale pre-commit file from another host. + try { + return await withAdvisoryFileLock(filePath, () => this.readFreshUnderAdvisoryLock(taskId, filePath), { + retries: ADVISORY_READ_LOCK_RETRIES, + }) + } catch (error) { + // The advisory lock itself could not be acquired or was + // compromised: durability is unknowable — never "missing", and + // the cache is left untouched. + return { kind: "error", reason: "read", error } + } + }) + } + + /** + * The file read behind {@link readFresh}'s advisory lock. Only reachable + * while the per-task file cannot be mid-write by another host. + */ + private async readFreshUnderAdvisoryLock(taskId: string, filePath: string): Promise { + let raw: string + try { + raw = await fs.readFile(filePath, "utf8") + } catch (error) { + if (this.isFileNotFoundError(error)) { + this.cache.delete(taskId) + this.taskFileMtimes.delete(taskId) + return { kind: "missing" } + } + return { kind: "error", reason: "read", error } + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (error) { + return { kind: "error", reason: "parse", error } + } + + if (typeof parsed !== "object" || parsed === null || typeof (parsed as HistoryItem).id !== "string") { + return { + kind: "error", + reason: "incompatible", + error: new Error(`[TaskHistoryStore] readFresh: task ${taskId} record has no usable id`), + } + } + + const item = parsed as HistoryItem + + // Identity-strict: a record whose own id does not match the requested + // task id is incompatible with that key. It must never be cached under + // the requested key. + if (item.id !== taskId) { + return { + kind: "error", + reason: "incompatible", + error: new Error(`[TaskHistoryStore] readFresh: task ${taskId} record has mismatched id ${item.id}`), + } + } + + this.cache.set(taskId, item) + this.taskFileMtimes.delete(taskId) + return { kind: "found", item } + } + /** * Invalidate a single task's cache entry (re-read from disk on next access). */ diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 3e277ac867..624b6bc383 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -7,8 +7,10 @@ import * as os from "os" import type { HistoryItem } from "@roo-code/types" import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" +import { delegateTaskToChild } from "../taskLifecycle" import { GlobalFileNames } from "../../../shared/globalFileNames" import { ClineProvider } from "../../webview/ClineProvider" +import { withAdvisoryFileLock } from "../../../utils/advisoryFileLock" vi.mock("../../../utils/storage", () => ({ getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => { @@ -16,13 +18,21 @@ vi.mock("../../../utils/storage", () => ({ }), })) -// Mock safeWriteJson to use plain fs writes in tests (avoids proper-lockfile issues) -vi.mock("../../../utils/safeWriteJson", () => ({ - safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") - }), -})) +// Mock safeWriteJson to use plain fs writes in tests (avoids proper-lockfile +// issues for writes), while the advisory-lock primitives stay REAL: readFresh +// must coordinate with the actual proper-lockfile lock under test. +vi.mock("../../../utils/safeWriteJson", async (importOriginal) => { + const actual = await importOriginal() + return { + safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") + }), + withAdvisoryFileLock: actual.withAdvisoryFileLock, + ADVISORY_READ_LOCK_RETRIES: actual.ADVISORY_READ_LOCK_RETRIES, + LOCK_STALE_MS: actual.LOCK_STALE_MS, + } +}) function makeHistoryItem(overrides: Partial = {}): HistoryItem { return { @@ -851,4 +861,159 @@ describe("TaskHistoryStore", () => { expect(store.get("parent-store-guard")?.status).toBe("active") }) }) + + describe("delegation commit reconciliation (real store)", () => { + it("surfaces a reject-after-write as a durable parent delegation with optional child history", async () => { + // Write-through callback that throws AFTER the task file write lands — + // the production-realistic ambiguous-commit shape. + const writeThroughError = new Error("globalState write-through failed") + const failingStore = new TaskHistoryStore(tmpDir, { + onWrite: async () => { + throw writeThroughError + }, + }) + try { + // Seed the parent record directly on disk so the failing + // write-through only fires on the commit under test. + const parent = makeHistoryItem({ id: "reject-parent", status: "active", childIds: [] }) + const tasksDir = path.join(tmpDir, "tasks") + await fs.mkdir(path.join(tasksDir, "reject-parent"), { recursive: true }) + await fs.writeFile( + path.join(tasksDir, "reject-parent", GlobalFileNames.historyItem), + JSON.stringify(parent, null, "\t"), + "utf8", + ) + await failingStore.initialize() + + // No child history has been written at all: at the atomic commit + // boundary only parent history is guaranteed durable. + await expect( + failingStore.atomicReadAndUpdate("reject-parent", (current) => + delegateTaskToChild(current, "reject-child"), + ), + ).rejects.toThrow(writeThroughError) + + // The strict fresh read observes what actually persisted: the + // parent record is durably delegated to the attempted child even + // though the write was reported as a failure. + const parentRead = await failingStore.readFresh("reject-parent") + expect(parentRead).toEqual({ + kind: "found", + item: expect.objectContaining({ + id: "reject-parent", + status: "delegated", + awaitingChildId: "reject-child", + }), + }) + // The child record is absent — expected, not incoherent. + expect(await failingStore.readFresh("reject-child")).toEqual({ kind: "missing" }) + } finally { + failingStore.dispose() + } + }) + + it("distinguishes a definitively missing record from an unreadable one", async () => { + await store.initialize() + + // Never written: definitively absent. + expect(await store.readFresh("never-written")).toEqual({ kind: "missing" }) + + // Corrupt JSON on disk: exists but unreadable — durability unknowable. + const tasksDir = path.join(tmpDir, "tasks") + await fs.mkdir(path.join(tasksDir, "corrupt-task"), { recursive: true }) + await fs.writeFile(path.join(tasksDir, "corrupt-task", GlobalFileNames.historyItem), "not-json{{{", "utf8") + + const corrupt = await store.readFresh("corrupt-task") + expect(corrupt.kind).toBe("error") + if (corrupt.kind === "error") { + expect(corrupt.reason).toBe("parse") + } + }) + + it("never caches a record whose id does not match the requested task", async () => { + await store.initialize() + const tasksDir = path.join(tmpDir, "tasks") + await fs.mkdir(path.join(tasksDir, "task-mismatch"), { recursive: true }) + await fs.writeFile( + path.join(tasksDir, "task-mismatch", GlobalFileNames.historyItem), + JSON.stringify(makeHistoryItem({ id: "task-other" })), + "utf8", + ) + + const result = await store.readFresh("task-mismatch") + expect(result).toMatchObject({ kind: "error", reason: "incompatible" }) + // The foreign record is never cached under the requested key. + expect(store.get("task-mismatch")).toBeUndefined() + }) + + it("treats a record without a usable id as incompatible", async () => { + await store.initialize() + const tasksDir = path.join(tmpDir, "tasks") + await fs.mkdir(path.join(tasksDir, "task-noid"), { recursive: true }) + await fs.writeFile( + path.join(tasksDir, "task-noid", GlobalFileNames.historyItem), + JSON.stringify({ task: "no id here" }), + "utf8", + ) + + const result = await store.readFresh("task-noid") + expect(result).toMatchObject({ kind: "error", reason: "incompatible" }) + expect(store.get("task-noid")).toBeUndefined() + }) + + it("waits for a gated advisory-lock writer and sees the final durable record, never the rename gap", async () => { + await store.initialize() + const item = makeHistoryItem({ id: "task-lock" }) + await store.upsert(item) + + // A second store instance (another host in-process) reads while a + // writer holds the same advisory proper-lockfile lock. + const second = new TaskHistoryStore(tmpDir) + await second.initialize() + try { + const filePath = path.join(tmpDir, "tasks", "task-lock", GlobalFileNames.historyItem) + const finalItem = { ...item, status: "delegated" as const, awaitingChildId: "child-9" } + + let writerLockHeld!: () => void + const lockHeld = new Promise((resolve) => { + writerLockHeld = resolve + }) + let releaseWriter!: () => void + const releaseGate = new Promise((resolve) => { + releaseWriter = resolve + }) + + // The writer holds the advisory lock and simulates + // safeWriteJson's backup/commit rename window: the target file is + // MOVED AWAY while the lock is held, and the final record is + // written before the lock is released. + const writer = withAdvisoryFileLock(filePath, async () => { + writerLockHeld() + await fs.rename(filePath, `${filePath}.gap`) + await releaseGate + await fs.writeFile(filePath, JSON.stringify(finalItem), "utf8") + }) + await lockHeld + + // The reader starts while the file is inside the rename gap + // behind the held lock. A non-lock-aware read would see ENOENT + // here and misreport a definitively-missing record. + const readPromise = second.readFresh("task-lock") + const raced = await Promise.race([ + readPromise.then(() => "settled" as const), + new Promise((resolve) => setTimeout(() => resolve("pending" as const), 50)), + ]) + expect(raced).toBe("pending") + + releaseWriter() + await writer + // The read waits out the write and observes the final durable + // record — never the gap, never the stale pre-write state. + await expect(readPromise).resolves.toEqual({ kind: "found", item: finalItem }) + expect(second.get("task-lock")).toMatchObject({ status: "delegated", awaitingChildId: "child-9" }) + } finally { + second.dispose() + } + }) + }) }) diff --git a/src/core/task-persistence/__tests__/providerHandoff.spec.ts b/src/core/task-persistence/__tests__/providerHandoff.spec.ts index 55bd9663c5..ccdf0ab830 100644 --- a/src/core/task-persistence/__tests__/providerHandoff.spec.ts +++ b/src/core/task-persistence/__tests__/providerHandoff.spec.ts @@ -1,13 +1,25 @@ import { describe, expect, it } from "vitest" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + import { + applyProviderHandoffEvent, + createPreparedProviderHandoffContext, createProviderHandoffPlan, + createProviderHandoffTransaction, + classifyProviderHandoffProjectionResults, decideProviderHandoffProfile, getProviderHandoffActivationOptions, + initialProviderHandoffState, PRODUCTION_PROVIDER_HANDOFF_POLICY, publishProviderHandoffState, + deriveProviderHandoffProfileIntent, + redactProviderHandoffSecrets, shouldPublishProviderHandoffState, + type ProviderHandoffEvent, type ProviderHandoffPolicy, + type ProviderHandoffRejection, + type ProviderHandoffState, } from "../providerHandoff" describe("provider handoff contract", () => { @@ -23,6 +35,37 @@ describe("provider handoff contract", () => { }) }) + it("derives an explicit projection intent: named profiles set, locked profiles preserve, unnamed clear", () => { + expect(deriveProviderHandoffProfileIntent({ source: "saved", name: "saved-profile" })).toEqual({ + kind: "set", + name: "saved-profile", + }) + expect(deriveProviderHandoffProfileIntent({ source: "unsaved-current", name: "current" })).toEqual({ + kind: "set", + name: "current", + }) + // Locked handoffs must never rewrite the pinned identity. + expect(deriveProviderHandoffProfileIntent({ source: "locked-current", name: "pinned" })).toEqual({ + kind: "preserve", + }) + // No profile at all is an explicit clear, not a skipped write. + expect(deriveProviderHandoffProfileIntent({ source: "unsaved-current", name: undefined })).toEqual({ + kind: "clear", + }) + expect(deriveProviderHandoffProfileIntent({ source: "locked-current", name: undefined })).toEqual({ + kind: "clear", + }) + }) + + it("carries the derived intent on the prepared context", () => { + const prepared = createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "locked-current", name: "pinned", id: "pinned-id" }, + apiConfiguration: { apiProvider: providerIdentifiers.openai }, + }) + expect(prepared.profile.intent).toEqual({ kind: "preserve" }) + }) + it("selects the current profile while workspace profile locking is enabled", () => { expect( decideProviderHandoffProfile({ @@ -108,4 +151,697 @@ describe("provider handoff contract", () => { await publishProviderHandoffState(true, undefined, publish) expect(publish).toHaveBeenCalledOnce() }) + + it("deep-clones the api configuration so the prepared context aliases nothing", () => { + const source = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "sk-sentinel-123456", + openAiHeaders: { "x-unit": "abc" }, + } + const profile = { source: "saved" as const, name: "saved-profile", id: "saved-id" } + + const prepared = createPreparedProviderHandoffContext({ + requestedMode: "ask", + profile, + apiConfiguration: source, + persistModeProfileId: "saved-id", + }) + + expect(prepared.requestedMode).toBe("ask") + // The prepared profile carries the explicitly derived projection intent. + expect(prepared.profile).toEqual({ ...profile, intent: { kind: "set", name: "saved-profile" } }) + expect(prepared.persistModeProfileId).toBe("saved-id") + // Full profile data is preserved, including provider secret fields. + expect(prepared.apiConfiguration).toEqual(source) + + // Mutating the source after preparation cannot affect the context. + source.openRouterApiKey = "sk-rotated" + source.openAiHeaders["x-unit"] = "mutated" + expect(prepared.apiConfiguration.openRouterApiKey).toBe("sk-sentinel-123456") + expect(prepared.apiConfiguration.openAiHeaders?.["x-unit"]).toBe("abc") + + // The context shell is frozen: in-place mutation is a no-op in tests. + expect(Object.isFrozen(prepared)).toBe(true) + expect(Object.isFrozen(prepared.profile)).toBe(true) + expect(() => { + ;(prepared as { requestedMode?: string }).requestedMode = "code" + }).toThrow() + }) + + it("classifies named projection results by store, not by result index", () => { + // A profile-store write failure stays profile-store even when it is not + // the last entry, and a ContextProxy failure stays context-proxy even + // when it is. + expect( + classifyProviderHandoffProjectionResults([ + { operation: "global-mode", ok: true }, + { operation: "profile-store", ok: false, error: new Error("durable store rejected") }, + { operation: "provider-settings", ok: true }, + ]), + ).toEqual({ ok: false, boundary: "profile-store", failedOperation: "profile-store" }) + expect( + classifyProviderHandoffProjectionResults([ + { operation: "global-mode", ok: true }, + { operation: "provider-settings", ok: false, error: new Error("context write failed") }, + { operation: "profile-store", ok: true }, + ]), + ).toEqual({ ok: false, boundary: "context-proxy", failedOperation: "provider-settings" }) + // The durable profile metadata read belongs to the profile store. + expect(classifyProviderHandoffProjectionResults([{ operation: "profile-meta-read", ok: false }])).toEqual({ + ok: false, + boundary: "profile-store", + failedOperation: "profile-meta-read", + }) + // A clean batch synchronizes. + expect( + classifyProviderHandoffProjectionResults([ + { operation: "global-mode", ok: true }, + { operation: "profile-meta-read", ok: true }, + { operation: "global-config-meta", ok: true }, + { operation: "global-profile-name", ok: true }, + { operation: "provider-settings", ok: true }, + { operation: "profile-store", ok: true }, + ]), + ).toEqual({ ok: true }) + }) + + it("redacts provider secret values from error messages without touching other text", () => { + const apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "sk-super-secret-value", + rateLimitSeconds: 5, + } + const message = + 'Failed to project provider handoff state for openai/gpt-4: Error: invalid key "sk-super-secret-value"' + + const redacted = redactProviderHandoffSecrets(message, apiConfiguration) + + expect(redacted).not.toContain("sk-super-secret-value") + expect(redacted).toContain("[redacted]") + expect(redacted).toContain("openai/gpt-4") + // Non-secret configuration values are not treated as secrets. + expect(redactProviderHandoffSecrets("plain failure", apiConfiguration)).toBe("plain failure") + }) +}) + +describe("provider handoff transaction protocol", () => { + const GENERATION = "prepared-generation-label" + + function drive( + from: ProviderHandoffState, + events: ProviderHandoffEvent[], + ): { states: ProviderHandoffState[]; rejections: ProviderHandoffRejection[] } { + const states = [from] + const rejections: ProviderHandoffRejection[] = [] + for (const event of events) { + const transition = applyProviderHandoffEvent(states[states.length - 1]!, event) + if (transition.ok) { + states.push(transition.state) + } else { + rejections.push(transition.reason) + states.push(transition.state) + } + } + return { states, rejections } + } + + const happyPath: ProviderHandoffEvent[] = [ + { type: "prepare", generation: GENERATION }, + { type: "remove-parent" }, + { type: "create-child", generation: GENERATION }, + { type: "commit-delegation" }, + { type: "activate-context", generation: GENERATION }, + { type: "project-legacy", boundary: "profile-store", ok: true }, + { type: "start-child" }, + { type: "publish" }, + ] + + it("walks the legal happy path from initial to settled with one prepared generation", () => { + const { states, rejections } = drive(initialProviderHandoffState(), happyPath) + + expect(rejections).toEqual([]) + // Projection bookkeeping happens inside the context-active phase. + expect(states.map((state) => state.phase)).toEqual([ + "initial", + "prepared", + "parent-removed", + "child-created", + "delegation-committed", + "context-active", + "context-active", + "child-running", + "settled", + ]) + const settled = states[states.length - 1]! + expect(settled).toMatchObject({ + delegation: "committed", + contextAuthority: "child", + childPresence: "running", + publication: "child", + projection: "synchronized", + generation: GENERATION, + commitAttempts: 1, + }) + }) + + it("rejects every documented illegal ordering", () => { + const initial = initialProviderHandoffState() + + // remove-before-prepare + expect(applyProviderHandoffEvent(initial, { type: "remove-parent" })).toMatchObject({ + ok: false, + reason: "preparation-required", + }) + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + expect(prepared.ok).toBe(true) + if (!prepared.ok) throw new Error("unreachable") + + // create-before-remove + expect( + applyProviderHandoffEvent(prepared.state, { type: "create-child", generation: GENERATION }), + ).toMatchObject({ + ok: false, + reason: "parent-not-removed", + }) + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + expect(removed.ok).toBe(true) + if (!removed.ok) throw new Error("unreachable") + + // commit-before-child + expect(applyProviderHandoffEvent(removed.state, { type: "commit-delegation" })).toMatchObject({ + ok: false, + reason: "child-required", + }) + const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) + expect(created.ok).toBe(true) + if (!created.ok) throw new Error("unreachable") + + // context authority before commit + expect( + applyProviderHandoffEvent(created.state, { type: "activate-context", generation: GENERATION }), + ).toMatchObject({ + ok: false, + reason: "commit-required", + }) + const committed = applyProviderHandoffEvent(created.state, { type: "commit-delegation" }) + expect(committed.ok).toBe(true) + if (!committed.ok) throw new Error("unreachable") + + // start/publish before durable commit + context authority + expect(applyProviderHandoffEvent(committed.state, { type: "start-child" })).toMatchObject({ + ok: false, + reason: "context-activation-required", + }) + expect(applyProviderHandoffEvent(committed.state, { type: "publish" })).toMatchObject({ + ok: false, + reason: "child-not-running", + }) + + // clean abort rollback after a committed delegation + expect(applyProviderHandoffEvent(committed.state, { type: "rollback-restore", ok: true })).toMatchObject({ + ok: false, + reason: "rollback-not-active", + }) + expect(applyProviderHandoffEvent(committed.state, { type: "rollback-cleanup", ok: true })).toMatchObject({ + ok: false, + reason: "rollback-not-active", + }) + + // exactly one lifecycle commit + expect(applyProviderHandoffEvent(committed.state, { type: "commit-delegation" })).toMatchObject({ + ok: false, + reason: "commit-already-attempted", + }) + expect(applyProviderHandoffEvent(committed.state, { type: "commit-failed" })).toMatchObject({ + ok: false, + reason: "commit-already-attempted", + }) + }) + + it("binds child creation and context authority to the single prepared generation", () => { + const initial = initialProviderHandoffState() + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + if (!prepared.ok) throw new Error("unreachable") + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + if (!removed.ok) throw new Error("unreachable") + + expect( + applyProviderHandoffEvent(removed.state, { type: "create-child", generation: "other-generation" }), + ).toMatchObject({ + ok: false, + reason: "generation-mismatch", + }) + const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) + if (!created.ok) throw new Error("unreachable") + const committed = applyProviderHandoffEvent(created.state, { type: "commit-delegation" }) + if (!committed.ok) throw new Error("unreachable") + expect( + applyProviderHandoffEvent(committed.state, { type: "activate-context", generation: "other-generation" }), + ).toMatchObject({ + ok: false, + reason: "generation-mismatch", + }) + const activated = applyProviderHandoffEvent(committed.state, { + type: "activate-context", + generation: GENERATION, + }) + expect(activated.ok).toBe(true) + expect(activated.ok && activated.state.generation).toBe(GENERATION) + }) + + it("fails closed on preparation with a clean abort and no residue", () => { + const aborted = applyProviderHandoffEvent(initialProviderHandoffState(), { type: "prepare-failed" }) + expect(aborted.ok).toBe(true) + if (!aborted.ok) throw new Error("unreachable") + expect(aborted.state).toMatchObject({ + phase: "aborted", + parentPresence: "current", + childPresence: "absent", + delegation: "none", + publication: "none", + projection: "original", + failure: { boundary: "preparation" }, + rollbackFailures: [], + }) + expect(applyProviderHandoffEvent(aborted.state, { type: "start-child" })).toMatchObject({ + ok: false, + reason: "terminal-state", + }) + }) + + it("restores the parent after a child-creation failure, degrading visibly when restoration fails", () => { + const initial = initialProviderHandoffState() + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + if (!prepared.ok) throw new Error("unreachable") + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + if (!removed.ok) throw new Error("unreachable") + + // Clean: restoration succeeds. + const failing = applyProviderHandoffEvent(removed.state, { type: "create-child-failed" }) + if (!failing.ok) throw new Error("unreachable") + expect(failing.state).toMatchObject({ phase: "aborting", failure: { boundary: "child-creation" } }) + expect(applyProviderHandoffEvent(failing.state, { type: "rollback-cleanup", ok: true })).toMatchObject({ + ok: false, + reason: "rollback-not-active", + }) + const restored = applyProviderHandoffEvent(failing.state, { type: "rollback-restore", ok: true }) + expect(restored.ok && restored.state.phase).toBe("aborted") + + // Degraded: restoration fails and stays labeled. + const failedRestore = applyProviderHandoffEvent(failing.state, { type: "rollback-restore", ok: false }) + expect(failedRestore.ok && failedRestore.state.phase).toBe("degraded-abort") + if (!failedRestore.ok) throw new Error("unreachable") + expect(failedRestore.state).toMatchObject({ + failure: { boundary: "child-creation" }, + rollbackFailures: ["parent-restoration"], + }) + // Each rollback step runs at most once. + expect(applyProviderHandoffEvent(failedRestore.state, { type: "rollback-restore", ok: true })).toMatchObject({ + ok: false, + reason: "terminal-state", + }) + }) + + it("forbids rollback while a failed commit's durability is unresolved", () => { + const initial = initialProviderHandoffState() + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + if (!prepared.ok) throw new Error("unreachable") + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + if (!removed.ok) throw new Error("unreachable") + const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) + if (!created.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + if (!failed.ok) throw new Error("unreachable") + + // Production reconciles the durability before any destructive step. + expect(applyProviderHandoffEvent(failed.state, { type: "rollback-cleanup", ok: true })).toMatchObject({ + ok: false, + reason: "commit-durability-unresolved", + }) + expect(applyProviderHandoffEvent(failed.state, { type: "rollback-restore", ok: true })).toMatchObject({ + ok: false, + reason: "commit-durability-unresolved", + }) + }) + + it("observes commit durability: uncommitted aborts cleanly, committed returns to the success path, incoherent degrades without rollback", () => { + const initial = initialProviderHandoffState() + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + if (!prepared.ok) throw new Error("unreachable") + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + if (!removed.ok) throw new Error("unreachable") + const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) + if (!created.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + if (!failed.ok) throw new Error("unreachable") + expect(failed.state).toMatchObject({ + phase: "aborting", + commitAttempts: 1, + failure: { boundary: "delegation-commit", commitDurability: "unresolved" }, + }) + + // Observation 1: the write never persisted. Cleanup + restore -> clean abort. + const uncommitted = applyProviderHandoffEvent(failed.state, { + type: "observe-commit-durability", + durability: "uncommitted", + }) + if (!uncommitted.ok) throw new Error("unreachable") + const cleaned = applyProviderHandoffEvent(uncommitted.state, { type: "rollback-cleanup", ok: true }) + if (!cleaned.ok) throw new Error("unreachable") + const aborted = applyProviderHandoffEvent(cleaned.state, { type: "rollback-restore", ok: true }) + expect(aborted.ok && aborted.state.phase).toBe("aborted") + expect(aborted.ok && aborted.state.failure).toMatchObject({ + boundary: "delegation-commit", + commitDurability: "uncommitted", + }) + + // Observation 2: the write persisted despite the observed failure. + // Authoritative reconciliation keeps the durable delegation and returns + // to the committed success path (context activation onwards); no + // rollback may run against the committed lineage. + const committed = applyProviderHandoffEvent(failed.state, { + type: "observe-commit-durability", + durability: "committed", + }) + if (!committed.ok) throw new Error("unreachable") + expect(committed.state).toMatchObject({ + phase: "delegation-committed", + delegation: "committed", + parentPresence: "removed", + childPresence: "paused", + failure: { boundary: "delegation-commit", commitDurability: "committed" }, + }) + expect(applyProviderHandoffEvent(committed.state, { type: "rollback-cleanup", ok: true })).toMatchObject({ + ok: false, + reason: "rollback-not-active", + }) + const activated = applyProviderHandoffEvent(committed.state, { + type: "activate-context", + generation: GENERATION, + }) + if (!activated.ok) throw new Error("unreachable") + expect(activated.ok && activated.state.phase).toBe("context-active") + + // Observation 3: the re-read failed or the lineage is incoherent. The + // terminal degrades without any destructive step: the child stays + // paused and the parent record untouched. + const incoherent = applyProviderHandoffEvent(failed.state, { + type: "observe-commit-durability", + durability: "incoherent", + }) + if (!incoherent.ok) throw new Error("unreachable") + expect(incoherent.state).toMatchObject({ + phase: "degraded-abort", + childPresence: "paused", + parentPresence: "removed", + rollbackFailures: [], + failure: { boundary: "delegation-commit", commitDurability: "incoherent" }, + }) + expect(applyProviderHandoffEvent(incoherent.state, { type: "rollback-cleanup", ok: true })).toMatchObject({ + ok: false, + reason: "terminal-state", + }) + }) + + it("labels cleanup failure on the degraded abort terminal", () => { + const initial = initialProviderHandoffState() + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + if (!prepared.ok) throw new Error("unreachable") + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + if (!removed.ok) throw new Error("unreachable") + const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) + if (!created.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + if (!failed.ok) throw new Error("unreachable") + const resolved = applyProviderHandoffEvent(failed.state, { + type: "observe-commit-durability", + durability: "uncommitted", + }) + if (!resolved.ok) throw new Error("unreachable") + const cleanupFailed = applyProviderHandoffEvent(resolved.state, { type: "rollback-cleanup", ok: false }) + if (!cleanupFailed.ok) throw new Error("unreachable") + expect(cleanupFailed.state).toMatchObject({ childPresence: "paused", rollbackFailures: ["child-cleanup"] }) + const restored = applyProviderHandoffEvent(cleanupFailed.state, { type: "rollback-restore", ok: true }) + expect(restored.ok && restored.state.phase).toBe("degraded-abort") + expect(restored.ok && restored.state.rollbackFailures).toEqual(["child-cleanup"]) + // Restore may not run before cleanup was attempted. + const fresh = applyProviderHandoffEvent(resolved.state, { type: "rollback-restore", ok: true }) + expect(fresh).toMatchObject({ ok: false, reason: "cleanup-not-attempted" }) + }) + + it("permits a projection failure after the commit without invalidating child authority", () => { + const initial = initialProviderHandoffState() + const activated = drive(initial, happyPath.slice(0, 5)).states[5]! + expect(activated.phase).toBe("context-active") + expect(activated.contextAuthority).toBe("child") + + const failed = applyProviderHandoffEvent(activated, { + type: "project-legacy", + boundary: "context-proxy", + ok: false, + }) + if (!failed.ok) throw new Error("unreachable") + expect(failed.state).toMatchObject({ + projection: "stale", + projectionFailure: "context-proxy", + contextAuthority: "child", + delegation: "committed", + }) + // Projection is best-effort and single-shot. + expect( + applyProviderHandoffEvent(failed.state, { type: "project-legacy", boundary: "profile-store", ok: true }), + ).toMatchObject({ + ok: false, + reason: "projection-already-attempted", + }) + // A stale projection still settles with the child running. + const started = applyProviderHandoffEvent(failed.state, { type: "start-child" }) + if (!started.ok) throw new Error("unreachable") + const published = applyProviderHandoffEvent(started.state, { type: "publish" }) + expect(published.ok && published.state.phase).toBe("settled") + expect(published.ok && published.state.publication).toBe("child") + }) + + it("starts the child without awaiting the legacy projection, which may settle afterwards", () => { + const initial = initialProviderHandoffState() + const activated = drive(initial, happyPath.slice(0, 5)).states[5]! + + // Start with the projection still original: the child never waits for + // background legacy projection work. + const started = applyProviderHandoffEvent(activated, { type: "start-child" }) + if (!started.ok) throw new Error("unreachable") + expect(started.state).toMatchObject({ + phase: "child-running", + childPresence: "running", + projection: "original", + contextAuthority: "child", + }) + + // The unresolved projection may settle while the child runs. + const projected = applyProviderHandoffEvent(started.state, { + type: "project-legacy", + boundary: "profile-store", + ok: true, + }) + if (!projected.ok) throw new Error("unreachable") + expect(projected.state).toMatchObject({ phase: "child-running", projection: "synchronized" }) + + // Publication still settles from child-running. + const published = applyProviderHandoffEvent(projected.state, { type: "publish" }) + expect(published.ok && published.state.phase).toBe("settled") + + // A projection that never recorded before settlement is dropped as + // inert bookkeeping, never replayed on a terminal state. + const settledOriginal = applyProviderHandoffEvent(started.state, { type: "publish" }) + if (!settledOriginal.ok) throw new Error("unreachable") + expect( + applyProviderHandoffEvent(settledOriginal.state, { + type: "project-legacy", + boundary: "context-proxy", + ok: true, + }), + ).toMatchObject({ ok: false, reason: "terminal-state" }) + }) + + it("records the fresh parent observation on the failure without changing durability semantics", () => { + const initial = initialProviderHandoffState() + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + if (!prepared.ok) throw new Error("unreachable") + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + if (!removed.ok) throw new Error("unreachable") + const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) + if (!created.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + if (!failed.ok) throw new Error("unreachable") + + const observed = applyProviderHandoffEvent(failed.state, { + type: "observe-commit-durability", + durability: "incoherent", + observation: "unreadable", + }) + if (!observed.ok) throw new Error("unreachable") + expect(observed.state.failure).toMatchObject({ + boundary: "delegation-commit", + commitDurability: "incoherent", + commitObservation: "unreadable", + }) + }) + + it("records the diagnostic contradictory-child and drifted observations without changing safety", () => { + const driveToFailedCommit = () => { + const initial = initialProviderHandoffState() + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + if (!prepared.ok) throw new Error("unreachable") + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + if (!removed.ok) throw new Error("unreachable") + const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) + if (!created.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + if (!failed.ok) throw new Error("unreachable") + return failed.state + } + + // A present child record contradicting the exact parent delegation is + // its own diagnostic label — incoherent, never committed. + const contradictory = applyProviderHandoffEvent(driveToFailedCommit(), { + type: "observe-commit-durability", + durability: "incoherent", + observation: "contradictory-child", + }) + if (!contradictory.ok) throw new Error("unreachable") + expect(contradictory.state.phase).toBe("degraded-abort") + expect(contradictory.state.failure).toMatchObject({ + commitDurability: "incoherent", + commitObservation: "contradictory-child", + }) + + // A preimage drift is likewise its own label with unchanged semantics: + // degraded, non-destructive, no rollback. + const drifted = applyProviderHandoffEvent(driveToFailedCommit(), { + type: "observe-commit-durability", + durability: "incoherent", + observation: "drifted", + }) + if (!drifted.ok) throw new Error("unreachable") + expect(drifted.state.phase).toBe("degraded-abort") + expect(drifted.state.failure).toMatchObject({ + commitDurability: "incoherent", + commitObservation: "drifted", + }) + expect(drifted.state.rollbackFailures).toEqual([]) + }) + + it("carries no secrets or configuration in protocol state", () => { + const { states } = drive(initialProviderHandoffState(), [ + ...happyPath.slice(0, 5), + { type: "project-legacy", boundary: "profile-store", ok: false }, + ...happyPath.slice(6), + ]) + const expectedKeys = [ + "phase", + "parentPresence", + "childPresence", + "delegation", + "generation", + "contextAuthority", + "projection", + "projectionFailure", + "publication", + "failure", + "rollbackFailures", + "commitAttempts", + ] + for (const state of states) { + expect(Object.keys(state).sort()).toEqual([...expectedKeys].sort()) + } + // Every primitive string in protocol state belongs to the fixed + // vocabulary: phase/presence/boundary labels plus the caller's opaque + // generation label. No configuration or secret-shaped value can appear. + const allowed = new Set([ + // phases + "initial", + "prepared", + "parent-removed", + "child-created", + "delegation-committed", + "context-active", + "child-running", + "settled", + "aborting", + "aborted", + "degraded-abort", + // presence, durability, authority, projection, publication + "current", + "removed", + "restored", + "absent", + "paused", + "running", + "none", + "committed", + "parent", + "child", + "original", + "synchronized", + "stale", + // failure and projection boundaries, durability observations + "preparation", + "child-creation", + "delegation-commit", + "child-cleanup", + "parent-restoration", + "profile-store", + "context-proxy", + "queue", + "unresolved", + "uncommitted", + "incoherent", + // the single caller-supplied opaque label + GENERATION, + ]) + for (const state of states) { + const stack: unknown[] = [state] + while (stack.length > 0) { + const value = stack.pop() + if (typeof value === "string") { + expect(allowed.has(value)).toBe(true) + } else if (value !== null && typeof value === "object") { + stack.push(...Object.values(value)) + } + } + } + expect(JSON.stringify(states)).not.toContain("sk-") + expect(JSON.stringify(states)).not.toContain("apiKey") + }) +}) + +describe("provider handoff transaction wrapper", () => { + it("binds one generation, advances landmarks, and never throws on rejected advances", () => { + const transaction = createProviderHandoffTransaction() + expect(transaction.generation).toMatch(/^handoff-generation-/) + + expect(transaction.advance({ type: "prepare" }).ok).toBe(true) + // Rejected advances never throw and never change the snapshot. + expect(transaction.advance({ type: "create-child" })).toMatchObject({ ok: false, reason: "parent-not-removed" }) + expect(transaction.snapshot().phase).toBe("prepared") + expect(transaction.advance({ type: "remove-parent" }).ok).toBe(true) + expect(transaction.advance({ type: "commit-delegation" })).toMatchObject({ + ok: false, + reason: "child-required", + }) + expect(transaction.snapshot().phase).toBe("parent-removed") + expect(transaction.advance({ type: "create-child" }).ok).toBe(true) + expect(transaction.snapshot()).toMatchObject({ + phase: "child-created", + generation: transaction.generation, + }) + }) + + it("generates distinct opaque generations per transaction", () => { + const first = createProviderHandoffTransaction() + const second = createProviderHandoffTransaction() + expect(first.generation).not.toBe(second.generation) + }) }) diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 7286112074..c80761c466 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -13,16 +13,48 @@ export { } from "./taskMessages" export { taskMetadata } from "./taskMetadata" export { ensureMessageIdentifiers } from "./mergeMessageSnapshots" -export { TaskHistoryStore } from "./TaskHistoryStore" +export { TaskHistoryStore, type StrictTaskReadResult } from "./TaskHistoryStore" export { + applyProviderHandoffEvent, + classifyProviderHandoffProjectionResults, + createPreparedProviderHandoffContext, createProviderHandoffPlan, + createProviderHandoffTransaction, decideProviderHandoffProfile, getProviderHandoffActivationOptions, + initialProviderHandoffState, PRODUCTION_PROVIDER_HANDOFF_POLICY, publishProviderHandoffState, + deriveProviderHandoffProfileIntent, + redactProviderHandoffSecrets, + providerHandoffProjectionBoundary, shouldPublishProviderHandoffState, + type NamedProviderHandoffProjectionResult, + type PreparedProviderHandoffContext, + type PreparedProviderHandoffProfile, + type ProviderHandoffAction, + type ProviderHandoffChildPresence, + type ProviderHandoffCommitDurability, + type ProviderHandoffContextAuthority, + type ProviderHandoffDelegationDurability, + type ProviderHandoffEvent, + type ProviderHandoffFailure, + type ProviderHandoffFailureBoundary, + type ProviderHandoffParentPresence, + type ProviderHandoffPhase, type ProviderHandoffPolicy, + type ProviderHandoffProjectionBoundary, + type ProviderHandoffProjectionOperation, + type ProviderHandoffProjectionOutcome, + type ProviderHandoffProjectionState, + type ProviderHandoffPublicationState, type ProviderHandoffProfileDecision, + type ProviderHandoffProfileIntent, + type ProviderHandoffCommitObservation, + type ProviderHandoffRejection, + type ProviderHandoffState, + type ProviderHandoffTransaction, + type ProviderHandoffTransition, type ProviderProfileRef, } from "./providerHandoff" export { diff --git a/src/core/task-persistence/providerHandoff.ts b/src/core/task-persistence/providerHandoff.ts index 1d790d644a..1b2aa1fe9d 100644 --- a/src/core/task-persistence/providerHandoff.ts +++ b/src/core/task-persistence/providerHandoff.ts @@ -1,3 +1,5 @@ +import { isSecretStateKey, type ProviderSettings } from "@roo-code/types" + export interface ProviderProfileRef { name: string id?: string @@ -64,6 +66,103 @@ export function decideProviderHandoffProfile(params: { } } +/** + * Immutable execution context produced by read-only provider handoff + * preparation. The delegating parent captures this snapshot while it is still + * the current task; after the durable delegation commit it becomes the + * authoritative task-local mode/profile/apiConfiguration of the child. Legacy + * global writes that happen afterwards are projections only and can never + * change what the child executes. + */ +/** + * Explicit profile projection intent carried by the prepared handoff context + * and the stale-projection marker. The three kinds replace the previous + * ambiguous "undefined name means skip the write" behavior: + * + * - `set`: durably project this profile identity onto legacy global state and + * the profile store (saved and unsaved-current handoffs with a profile). + * - `preserve`: perform no profile-identity write at all. Used when the + * profile identity is pinned across modes (workspace profile locking): the + * handoff must never rewrite what the user pinned, even as background work. + * - `clear`: the handoff carries no profile identity (no current profile). + * The child executes with `apiConfigName: undefined` and the projection must + * explicitly write `undefined` (not skip) so legacy global state and + * publication stop claiming a profile that no longer exists. + */ +export type ProviderHandoffProfileIntent = + | { readonly kind: "preserve" } + | { readonly kind: "set"; readonly name: string } + | { readonly kind: "clear" } + +/** + * Derive the explicit projection intent from a prepared profile decision. + * A named profile projects as `set` — except under workspace profile locking, + * where the identity is user-pinned and the projection must not rewrite it + * (`preserve`). A profile without a name is an explicit `clear`. + */ +export function deriveProviderHandoffProfileIntent(profile: { + source: ProviderHandoffProfileDecision["source"] + name: string | undefined +}): ProviderHandoffProfileIntent { + if (profile.name === undefined) return { kind: "clear" } + if (profile.source === "locked-current") return { kind: "preserve" } + return { kind: "set", name: profile.name } +} + +export interface PreparedProviderHandoffProfile { + /** Which rule produced this profile decision (saved / unsaved-current / locked-current). */ + readonly source: ProviderHandoffProfileDecision["source"] + readonly name: string | undefined + readonly id: string | undefined + /** Explicit post-commit projection intent for the profile identity. */ + readonly intent: ProviderHandoffProfileIntent +} + +export interface PreparedProviderHandoffContext { + readonly requestedMode: string + readonly profile: PreparedProviderHandoffProfile + /** Deep-cloned full API configuration, including provider secret fields. Never log or serialize this context. */ + readonly apiConfiguration: ProviderSettings + /** Post-commit intent: durably map this profile id to the requested mode (unsaved mode defaults, and saved-profile parity). */ + readonly persistModeProfileId: string | undefined +} + +/** + * Build a frozen, deep-cloned handoff context. Callers must pass data they do + * not need to mutate afterwards: the prepared context shares no object identity + * with its inputs, and later mutation of the inputs cannot affect the child. + */ +export function createPreparedProviderHandoffContext(params: { + requestedMode: string + /** Profile identity without intent; the intent is derived explicitly below. */ + profile: Omit + apiConfiguration: ProviderSettings + persistModeProfileId?: string +}): PreparedProviderHandoffContext { + const context: PreparedProviderHandoffContext = Object.freeze({ + requestedMode: params.requestedMode, + profile: Object.freeze({ ...params.profile, intent: deriveProviderHandoffProfileIntent(params.profile) }), + apiConfiguration: Object.freeze(structuredClone(params.apiConfiguration)), + persistModeProfileId: params.persistModeProfileId, + }) + return context +} + +/** + * Best-effort secret redaction for error messages logged around handoff + * state. Removes values of provider secret fields so projection failures can + * be logged without leaking credentials. + */ +export function redactProviderHandoffSecrets(message: string, apiConfiguration: ProviderSettings): string { + let redacted = message + for (const [key, value] of Object.entries(apiConfiguration)) { + if (isSecretStateKey(key) && typeof value === "string" && value.length > 0) { + redacted = redacted.split(value).join("[redacted]") + } + } + return redacted +} + export function getProviderHandoffActivationOptions(policy: ProviderHandoffPolicy) { return { skipCurrentTaskRebuild: !policy.mutateExposedTask, @@ -86,3 +185,462 @@ export async function publishProviderHandoffState( ): Promise { if (shouldPublishProviderHandoffState(targetTaskIsNotNull, policy)) await publish() } + +// --------------------------------------------------------------------------- +// Provider handoff transaction protocol +// +// A pure, secret-free state machine for the delegation handoff transaction +// that `ClineProvider.delegateParentAndOpenChild` implements and +// `scripts/check-provider-handoff.ts` model-checks. It validates ordering and +// records coarse bookkeeping only: it never persists anything and never +// touches profile or secret data. Generations and profiles are opaque labels. +// --------------------------------------------------------------------------- + +/** Coarse phase of the delegation handoff transaction. */ +export type ProviderHandoffPhase = + | "initial" + | "prepared" + | "parent-removed" + | "child-created" + | "delegation-committed" + | "context-active" + | "child-running" + | "settled" + | "aborting" + | "aborted" + | "degraded-abort" + +/** Coarse boundary labels for handoff failures. Never carry error details or secrets. */ +export type ProviderHandoffFailureBoundary = + | "preparation" + | "child-creation" + | "delegation-commit" + | "child-cleanup" + | "parent-restoration" + +/** Which legacy projection store failed post-commit, or that the queued projection never ran. */ +export type ProviderHandoffProjectionBoundary = "profile-store" | "context-proxy" | "queue" + +/** + * Named post-commit projection operations. Failures are classified per + * operation instead of by dynamic result-array index so the coarse + * profile-store versus ContextProxy boundary stays accurate when the write + * list changes. + */ +export type ProviderHandoffProjectionOperation = + | "global-mode" + | "profile-meta-read" + | "global-config-meta" + | "global-profile-name" + | "provider-settings" + | "profile-store" + +/** Outcome of one named projection operation. `error` is never protocol state. */ +export interface NamedProviderHandoffProjectionResult { + readonly operation: ProviderHandoffProjectionOperation + readonly ok: boolean + /** Failure reason when `ok` is false; used for redacted logging only. */ + readonly error?: unknown +} + +/** The coarse legacy store a named projection operation belongs to. */ +export function providerHandoffProjectionBoundary( + operation: ProviderHandoffProjectionOperation, +): Exclude { + switch (operation) { + // Reads/writes of the durable profile store file. + case "profile-meta-read": + case "profile-store": + return "profile-store" + // Everything else is legacy global ContextProxy state. + default: + return "context-proxy" + } +} + +/** + * Classify named projection results into the coarse projection outcome. The + * first failed operation determines the boundary; `queue` is never produced + * here because it means the queued batch never ran at all. + */ +export function classifyProviderHandoffProjectionResults( + results: readonly NamedProviderHandoffProjectionResult[], +): ProviderHandoffProjectionOutcome { + const firstFailed = results.find((result) => !result.ok) + if (!firstFailed) return { ok: true } + return { + ok: false, + boundary: providerHandoffProjectionBoundary(firstFailed.operation), + failedOperation: firstFailed.operation, + } +} + +/** + * Durability of a failed delegation commit attempt. Production can only + * observe "unresolved": the store write rejected, but the write may still have + * persisted before the failure surfaced. The model explores both observations. + */ +export type ProviderHandoffCommitDurability = "unresolved" | "uncommitted" | "committed" | "incoherent" + +export type ProviderHandoffParentPresence = "current" | "removed" | "restored" +export type ProviderHandoffChildPresence = "absent" | "paused" | "running" +export type ProviderHandoffDelegationDurability = "none" | "committed" +export type ProviderHandoffContextAuthority = "parent" | "child" +export type ProviderHandoffProjectionState = "original" | "synchronized" | "stale" +export type ProviderHandoffPublicationState = "none" | "child" + +/** + * What the strict fresh parent re-read observed after a rejected delegation + * commit. Coarse, secret-free labels for the reconciliation decision: + * + * - `exact`: the parent record is durably delegated to the attempted child + * (the child record is optional; only a PRESENT child record that + * contradicts the lineage makes the observation incoherent — see + * `contradictory-child`). + * - `contradictory-child`: the parent is delegated to the attempted child but + * a present child record contradicts that lineage (incoherent). + * - `other-child`: the parent record shows a delegation to a different child. + * - `unchanged`: the parent record exactly matches the safe nondelegated + * preimage captured before the commit attempt — nothing persisted. + * - `drifted`: the parent record matches neither the attempted delegation nor + * the preimage — another writer moved it (incoherent). + * - `missing`: the parent record is absent. + * - `unreadable`: the parent record exists but could not be read or parsed. + * + * Safety is label-independent: only `exact` continues as committed and only + * `unchanged` (the exact preimage) permits the rollback; every other label is + * a non-destructive incoherent observation. + */ +export type ProviderHandoffCommitObservation = + | "exact" + | "contradictory-child" + | "other-child" + | "unchanged" + | "drifted" + | "missing" + | "unreadable" + +/** Primary failure that diverted the transaction onto the abort/rollback path. */ +export interface ProviderHandoffFailure { + readonly boundary: ProviderHandoffFailureBoundary + readonly commitDurability?: ProviderHandoffCommitDurability + /** Which fresh parent observation resolved a failed commit's durability. */ + readonly commitObservation?: ProviderHandoffCommitObservation +} + +/** + * Secret-free, coarse protocol state. Profile and generation values are + * opaque labels; no provider settings, API keys, or task payloads appear here. + */ +export interface ProviderHandoffState { + readonly phase: ProviderHandoffPhase + readonly parentPresence: ProviderHandoffParentPresence + readonly childPresence: ProviderHandoffChildPresence + readonly delegation: ProviderHandoffDelegationDurability + /** Opaque label of the one prepared execution-context generation. */ + readonly generation: string | undefined + /** Owner of the child execution context: the parent until the commit, the child after. */ + readonly contextAuthority: ProviderHandoffContextAuthority + readonly projection: ProviderHandoffProjectionState + readonly projectionFailure: ProviderHandoffProjectionBoundary | undefined + readonly publication: ProviderHandoffPublicationState + readonly failure: ProviderHandoffFailure | undefined + /** Rollback step failures in the order they were observed. */ + readonly rollbackFailures: readonly ProviderHandoffFailureBoundary[] + /** Number of delegation commit attempts (successful or failed). At most one. */ + readonly commitAttempts: number +} + +/** Pure protocol events. The three generation-carrying events bind the prepared context. */ +export type ProviderHandoffEvent = + | { type: "prepare"; generation: string } + | { type: "prepare-failed" } + | { type: "remove-parent" } + | { type: "create-child"; generation: string } + | { type: "create-child-failed" } + | { type: "commit-delegation" } + | { type: "commit-failed" } + | { + type: "observe-commit-durability" + durability: "uncommitted" | "committed" | "incoherent" + /** Coarse observation label recorded on the failure, never protocol state. */ + observation?: ProviderHandoffCommitObservation + } + | { type: "activate-context"; generation: string } + | { type: "project-legacy"; boundary: ProviderHandoffProjectionBoundary; ok: boolean } + | { type: "start-child" } + | { type: "publish" } + | { type: "rollback-cleanup"; ok: boolean } + | { type: "rollback-restore"; ok: boolean } + +type GenerationCarryingAction = Extract + +/** Protocol events without generation labels; used by the transaction wrapper. */ +export type ProviderHandoffAction = + | Exclude + | Omit + +/** Why a protocol event was rejected. Naming mirrors the guarded ordering rule. */ +export type ProviderHandoffRejection = + | "unexpected-event" + | "preparation-required" + | "parent-not-removed" + | "child-required" + | "commit-required" + | "context-activation-required" + | "child-not-running" + | "commit-already-attempted" + | "commit-not-failed" + | "commit-durability-resolved" + | "generation-mismatch" + | "rollback-not-active" + | "rollback-already-attempted" + | "commit-durability-unresolved" + | "cleanup-not-attempted" + | "projection-already-attempted" + | "terminal-state" + +export type ProviderHandoffTransition = + | { ok: true; state: ProviderHandoffState } + | { ok: false; state: ProviderHandoffState; reason: ProviderHandoffRejection } + +export function initialProviderHandoffState(): ProviderHandoffState { + return { + phase: "initial", + parentPresence: "current", + childPresence: "absent", + delegation: "none", + generation: undefined, + contextAuthority: "parent", + projection: "original", + projectionFailure: undefined, + publication: "none", + failure: undefined, + rollbackFailures: [], + commitAttempts: 0, + } +} + +function accept(state: ProviderHandoffState): ProviderHandoffTransition { + return { ok: true, state } +} + +function reject(state: ProviderHandoffState, reason: ProviderHandoffRejection): ProviderHandoffTransition { + return { ok: false, state, reason } +} + +/** Terminal abort: degraded when a durable delegation or any rollback failure remains visible. */ +function settleAbort(state: ProviderHandoffState): ProviderHandoffState { + const degraded = state.delegation === "committed" || state.rollbackFailures.length > 0 + return { ...state, phase: degraded ? "degraded-abort" : "aborted" } +} + +/** + * Apply one protocol event. Total function: never throws. An illegal event + * leaves the state unchanged and reports a semantic rejection reason. + */ +export function applyProviderHandoffEvent( + state: ProviderHandoffState, + event: ProviderHandoffEvent, +): ProviderHandoffTransition { + if (state.phase === "settled" || state.phase === "aborted" || state.phase === "degraded-abort") { + return reject(state, "terminal-state") + } + + switch (event.type) { + case "prepare": + return state.phase === "initial" + ? accept({ ...state, phase: "prepared", generation: event.generation }) + : reject(state, "unexpected-event") + case "prepare-failed": + return state.phase === "initial" + ? accept({ ...state, phase: "aborted", failure: { boundary: "preparation" } }) + : reject(state, "unexpected-event") + case "remove-parent": + if (state.phase === "initial") return reject(state, "preparation-required") + if (state.phase !== "prepared") return reject(state, "unexpected-event") + return accept({ ...state, phase: "parent-removed", parentPresence: "removed" }) + case "create-child": + if (state.phase === "initial" || state.phase === "prepared") return reject(state, "parent-not-removed") + if (state.phase !== "parent-removed") return reject(state, "unexpected-event") + if (event.generation !== state.generation) return reject(state, "generation-mismatch") + return accept({ ...state, phase: "child-created", childPresence: "paused" }) + case "create-child-failed": + if (state.phase !== "parent-removed") return reject(state, "unexpected-event") + return accept({ ...state, phase: "aborting", failure: { boundary: "child-creation" } }) + case "commit-delegation": + if (state.commitAttempts > 0) return reject(state, "commit-already-attempted") + if (state.phase !== "child-created") return reject(state, "child-required") + return accept({ ...state, phase: "delegation-committed", delegation: "committed", commitAttempts: 1 }) + case "commit-failed": + if (state.commitAttempts > 0) return reject(state, "commit-already-attempted") + if (state.phase !== "child-created") return reject(state, "child-required") + return accept({ + ...state, + phase: "aborting", + commitAttempts: 1, + failure: { boundary: "delegation-commit", commitDurability: "unresolved" }, + }) + case "observe-commit-durability": { + const failure = state.failure + if (state.phase !== "aborting" || failure?.boundary !== "delegation-commit") { + return reject(state, "commit-not-failed") + } + if (failure.commitDurability !== "unresolved") return reject(state, "commit-durability-resolved") + const observedFailure: ProviderHandoffFailure = event.observation + ? { ...failure, commitObservation: event.observation } + : failure + if (event.durability === "committed") { + // Authoritative reconciliation: the rejected write actually + // persisted. The protocol returns to the committed success path + // (context activation, projection, start), keeping the retained + // failure as honest bookkeeping of the observed rejection. + return accept({ + ...state, + phase: "delegation-committed", + delegation: "committed", + failure: { ...observedFailure, commitDurability: "committed" }, + }) + } + if (event.durability === "incoherent") { + // Reconciliation could not read the records or the lineage does + // not match. Degraded terminal: keep the paused child and never + // restore the parent over potentially committed lineage. + return accept({ + ...state, + phase: "degraded-abort", + failure: { ...observedFailure, commitDurability: "incoherent" }, + }) + } + const next: ProviderHandoffState = { + ...state, + failure: { ...observedFailure, commitDurability: event.durability }, + } + // Once the rollback is complete (parent restored), the observation + // settles the abort; before that, cleanup/restore steps continue + // from the resolved view. + if (next.parentPresence === "restored") { + return accept(settleAbort(next)) + } + return accept(next) + } + case "activate-context": + if (state.phase !== "delegation-committed") { + return reject(state, state.contextAuthority === "child" ? "unexpected-event" : "commit-required") + } + if (event.generation !== state.generation) return reject(state, "generation-mismatch") + return accept({ ...state, phase: "context-active", contextAuthority: "child" }) + case "project-legacy": + // Legacy projection is background work: it may settle while the + // protocol is still in context-active OR after the child already + // started (child-running). It is single-shot either way. + if (state.phase !== "context-active" && state.phase !== "child-running") { + return reject( + state, + state.contextAuthority === "child" ? "unexpected-event" : "context-activation-required", + ) + } + if (state.projection !== "original") return reject(state, "projection-already-attempted") + return event.ok + ? accept({ ...state, projection: "synchronized" }) + : accept({ ...state, projection: "stale", projectionFailure: event.boundary }) + case "start-child": + // The child starts immediately after context activation; it must + // never await the (possibly slow or abandoned) legacy projection. + if (state.phase !== "context-active") return reject(state, "context-activation-required") + return accept({ ...state, phase: "child-running", childPresence: "running" }) + case "publish": + if (state.phase !== "child-running") return reject(state, "child-not-running") + return accept({ ...state, phase: "settled", publication: "child" }) + case "rollback-cleanup": + if (state.phase !== "aborting" || state.childPresence !== "paused") + return reject(state, "rollback-not-active") + // Production reconciles commit durability before any rollback, so a + // still-unresolved failed commit must not be rolled back destructively. + if (state.failure?.boundary === "delegation-commit" && state.failure.commitDurability === "unresolved") { + return reject(state, "commit-durability-unresolved") + } + // Each rollback step runs at most once, like the production rollback. + if (state.rollbackFailures.includes("child-cleanup")) return reject(state, "rollback-already-attempted") + return event.ok + ? accept({ ...state, childPresence: "absent" }) + : accept({ ...state, rollbackFailures: [...state.rollbackFailures, "child-cleanup"] }) + case "rollback-restore": { + if (state.phase !== "aborting") return reject(state, "rollback-not-active") + if (state.failure?.boundary === "delegation-commit" && state.failure.commitDurability === "unresolved") { + return reject(state, "commit-durability-unresolved") + } + if (state.rollbackFailures.includes("parent-restoration")) { + return reject(state, "rollback-already-attempted") + } + const cleanupHandled = state.childPresence === "absent" || state.rollbackFailures.includes("child-cleanup") + if (!cleanupHandled) return reject(state, "cleanup-not-attempted") + const next: ProviderHandoffState = event.ok + ? { ...state, parentPresence: "restored" } + : { ...state, rollbackFailures: [...state.rollbackFailures, "parent-restoration"] } + // An ambiguous commit observation stays open: production cannot know + // whether a rejected store write persisted, so the protocol stays in + // "aborting" until the durability is resolved (model-only event). + const durabilityUnresolved = + next.failure?.boundary === "delegation-commit" && next.failure.commitDurability === "unresolved" + if (durabilityUnresolved) return accept(next) + return accept(settleAbort(next)) + } + } +} + +function attachGeneration(action: ProviderHandoffAction, generation: string): ProviderHandoffEvent { + switch (action.type) { + case "prepare": + return { type: "prepare", generation } + case "create-child": + return { type: "create-child", generation } + case "activate-context": + return { type: "activate-context", generation } + default: + return action + } +} + +export interface ProviderHandoffTransaction { + /** Opaque label binding prepare, child creation, and context activation. */ + readonly generation: string + snapshot(): ProviderHandoffState + /** + * Advance the protocol. Never throws and never changes persisted state; a + * rejected event leaves the snapshot unchanged. Bookkeeping failures can + * therefore never obscure or alter real rollback behavior. + */ + advance(action: ProviderHandoffAction): ProviderHandoffTransition +} + +let providerHandoffTransactionCounter = 0 + +/** + * Create a transaction-scoped protocol bookkeeper for one delegation attempt. + * The wrapper binds a single opaque generation so production call sites can + * advance semantic landmarks without threading labels through the flow. + */ +export function createProviderHandoffTransaction(generation?: string): ProviderHandoffTransaction { + const resolvedGeneration = generation ?? `handoff-generation-${(providerHandoffTransactionCounter += 1)}` + let current = initialProviderHandoffState() + return { + generation: resolvedGeneration, + snapshot: () => current, + advance(action) { + const transition = applyProviderHandoffEvent(current, attachGeneration(action, resolvedGeneration)) + if (transition.ok) current = transition.state + return transition + }, + } +} + +/** Outcome of the best-effort post-commit legacy projection in production. */ +export interface ProviderHandoffProjectionOutcome { + /** False when at least one legacy projection write failed (projection is stale). */ + ok: boolean + /** Coarse boundary of the first failed write; "queue" when the queued batch never ran. */ + boundary?: ProviderHandoffProjectionBoundary + /** Named operation that failed first; undefined when ok or when the queue never ran. */ + failedOperation?: ProviderHandoffProjectionOperation +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fae796db6b..12a722ffb8 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -5,6 +5,8 @@ import crypto from "crypto" import { v7 as uuidv7 } from "uuid" import EventEmitter from "events" +import deepEqual from "fast-deep-equal" + import { AskIgnoredError } from "./AskIgnoredError" import { RateLimitClock, createRateLimitClock } from "./RateLimitClock" @@ -172,6 +174,44 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +/** + * All-or-none explicit execution context for provider delegation handoff. + * Internal to the extension runtime (deliberately not part of the public + * `CreateTaskOptions` package API): either every field is provided, or the + * context is omitted entirely — a partially explicit context would let the + * task silently fall back to inferring mode/profile from mutable global + * provider state, which is exactly what delegation must avoid. + */ +export interface TaskHandoffExecutionContext { + /** The requested mode the child must adopt synchronously at construction. */ + readonly mode: string + /** Sticky profile name; undefined when the handoff keeps no named profile. */ + readonly apiConfigName: string | undefined + /** Full API configuration (including provider secret fields) the child executes with. */ + readonly apiConfiguration: ProviderSettings +} + +/** + * Runtime completeness validation for {@link TaskHandoffExecutionContext}. + * The type system enforces all-or-none at compile time for internal callers; + * this guard also enforces it at runtime so a malformed (e.g. deserialized) + * options object cannot half-initialize a task. + */ +export function isCompleteTaskHandoffExecutionContext(value: unknown): value is TaskHandoffExecutionContext { + if (value === null || typeof value !== "object") { + return false + } + const candidate = value as Partial + return ( + typeof candidate.mode === "string" && + candidate.mode.length > 0 && + (candidate.apiConfigName === undefined || typeof candidate.apiConfigName === "string") && + candidate.apiConfiguration !== undefined && + typeof candidate.apiConfiguration === "object" && + candidate.apiConfiguration !== null + ) +} + export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider apiConfiguration: ProviderSettings @@ -193,6 +233,12 @@ export interface TaskOptions extends CreateTaskOptions { initialStatus?: "active" | "delegated" | "completed" | "interrupted" rateLimitClock?: RateLimitClock diffFuzzyThreshold?: number + /** + * Handoff-only explicit execution context (provider delegation). Internal; + * all-or-none — validated at runtime by + * {@link isCompleteTaskHandoffExecutionContext}. + */ + handoffExecutionContext?: TaskHandoffExecutionContext } type AssistantMessagePersistenceResult = boolean @@ -524,10 +570,20 @@ export class Task extends EventEmitter implements TaskLike { initialStatus, rateLimitClock, diffFuzzyThreshold, + handoffExecutionContext, }: TaskOptions) { super() this.resetAssistantMessagePersistence() + // All-or-none runtime validation of the handoff execution context: a + // partially explicit context must fail loudly instead of silently + // falling back to global-state inference mid-handoff. + if (handoffExecutionContext !== undefined && !isCompleteTaskHandoffExecutionContext(handoffExecutionContext)) { + throw new Error( + "[Task] handoffExecutionContext must be complete: mode, apiConfiguration, and apiConfigName are required together", + ) + } + if (startTask && !task && !images && !historyItem) { throw new Error("Either historyItem or task/images must be provided") } @@ -599,6 +655,15 @@ export class Task extends EventEmitter implements TaskLike { this.taskModeReady = Promise.resolve() this.taskApiConfigReady = Promise.resolve() TelemetryService.instance.captureTaskRestarted(this.taskId) + } else if (handoffExecutionContext !== undefined) { + // Handoff-only explicit execution context: adopt synchronously and never + // asynchronously infer mode/profile from mutable global provider state. + // Completeness was validated at the top of the constructor. + this._taskMode = handoffExecutionContext.mode + this._taskApiConfigName = handoffExecutionContext.apiConfigName + this.taskModeReady = Promise.resolve() + this.taskApiConfigReady = Promise.resolve() + TelemetryService.instance.captureTaskCreated(this.taskId) } else { // For new tasks, don't set the mode/apiConfigName yet - wait for async initialization. this._taskMode = undefined @@ -908,6 +973,33 @@ export class Task extends EventEmitter implements TaskLike { this._taskApiConfigName = apiConfigName } + /** + * Synchronously adopt an explicit provider-handoff execution context. + * + * Used only by provider delegation: after the durable delegation commit, the + * prepared handoff snapshot becomes authoritative for this task's mode, + * sticky profile, and API configuration. The task must not re-infer these + * from mutable global provider state. + * + * @internal + */ + public adoptHandoffExecutionContext(execution: { + mode: string + apiConfigName: string | undefined + apiConfiguration: ProviderSettings + }): void { + this._taskMode = execution.mode + this._taskApiConfigName = execution.apiConfigName + this.taskModeReady = Promise.resolve() + this.taskApiConfigReady = Promise.resolve() + + // The construction-time configuration is usually a clone of the same + // prepared snapshot; rebuild the handler only if it drifted by value. + if (!deepEqual(this.apiConfiguration, execution.apiConfiguration)) { + this.updateApiConfiguration(execution.apiConfiguration) + } + } + public setPendingTaskAction(pendingAction: PendingTaskAction): void { this.pendingAction = pendingAction } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 7418920cb1..bf4a6e3d07 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -646,6 +646,81 @@ describe("Cline", () => { }) }) + describe("handoff execution context", () => { + it("adopts a complete explicit execution context synchronously at construction", async () => { + const handoffConfig: ProviderSettings = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + } + const task = new Task({ + provider: mockProvider, + // Production passes the prepared snapshot as both the handler + // configuration and the handoff execution context. + apiConfiguration: handoffConfig, + task: "test task", + startTask: false, + handoffExecutionContext: { + mode: "ask", + apiConfigName: "handoff-profile", + apiConfiguration: handoffConfig, + }, + }) + + // Mode and sticky profile are authoritative immediately: no + // asynchronous inference from mutable global provider state. + await expect(task.getTaskMode()).resolves.toBe("ask") + await expect(task.getTaskApiConfigName()).resolves.toBe("handoff-profile") + // The handler configuration is the prepared snapshot, not global state. + expect(task.apiConfiguration).toEqual(handoffConfig) + }) + + it("rejects an incomplete handoff execution context at runtime", () => { + expect( + () => + new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + // Missing apiConfiguration: a partially explicit context must + // fail loudly instead of silently falling back to global state. + handoffExecutionContext: { mode: "ask", apiConfigName: undefined } as never, + }), + ).toThrow("handoffExecutionContext must be complete") + + expect( + () => + new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + // Empty mode with a configuration is equally incomplete. + handoffExecutionContext: { + mode: "", + apiConfigName: undefined, + apiConfiguration: mockApiConfig, + }, + }), + ).toThrow("handoffExecutionContext must be complete") + }) + + it("keeps ordinary initialization unchanged without a handoff context", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + expect(task.apiConfiguration).toEqual(mockApiConfig) + // Without an explicit context the mode and profile still initialize + // asynchronously from provider state (the proxy defaults). + await expect(task.getTaskMode()).resolves.toBe("architect") + await expect(task.getTaskApiConfigName()).resolves.toBe("default") + }) + }) + describe("constructor", () => { it("should always have diff strategy defined", async () => { const cline = new Task({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 58836271e9..5887e34ea7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -106,7 +106,7 @@ import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/provi import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" -import { Task } from "../task/Task" +import { isCompleteTaskHandoffExecutionContext, Task, type TaskHandoffExecutionContext } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" @@ -116,15 +116,26 @@ import { saveApiMessages, saveTaskMessages, TaskHistoryStore, + type StrictTaskReadResult, abandonDelegatedChild, completeDelegatedChild, + createPreparedProviderHandoffContext, + classifyProviderHandoffProjectionResults, createProviderHandoffPlan, + createProviderHandoffTransaction, decideProviderHandoffProfile, delegateTaskToChild, getProviderHandoffActivationOptions, interruptDelegatedChild, publishProviderHandoffState, + type NamedProviderHandoffProjectionResult, + type PreparedProviderHandoffContext, + type ProviderHandoffCommitObservation, type ProviderHandoffPolicy, + type ProviderHandoffProfileIntent, + type ProviderHandoffProjectionOperation, + type ProviderHandoffProjectionOutcome, + type ProviderHandoffTransaction, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" @@ -178,6 +189,19 @@ type GetStateOptions = { includeTaskHistory?: boolean } +/** + * Registration of an in-flight background handoff projection for a child task + * ID. `token` is an immutable projection identity allocated synchronously when + * the projection is initiated; every settlement must present the exact token. + * `admittedGeneration` is bound only when the bounded queue admits the + * projection and tightens the relevance fence to additionally require that + * exact generation. + */ +interface ProviderHandoffProjectionTargetRegistration { + token: number + admittedGeneration?: number +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -240,34 +264,230 @@ export class ClineProvider public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private providerProfileMutationQueue = Promise.resolve() private historyTaskCreationQueue = Promise.resolve() + /** + * Live AbortControllers for every queued-or-started profile mutation. + * Provider disposal aborts each one so queued-but-not-started callbacks + * are cancelled at admission and started callbacks stop before their next + * write. Controllers unregister when their operation settles. + */ + private profileMutationAbortControllers = new Set() + /** + * Bounded deadline for draining started (non-cancellable) profile writes + * during provider disposal. Past the deadline the queue is detached with + * handled promises — disposal is never unbounded. + */ + private static readonly PROFILE_MUTATION_DISPOSAL_DRAIN_TIMEOUT_MS = 5000 + /** + * Monotonic enqueue reservation counter. Every queued operation reserves + * the next number when it is enqueued, before it is admitted. Reservations + * order the log only: they never gate relevance or supersession, so a + * newer reservation that is cancelled before admission (zero writes) can + * never supersede an admitted older mutation. + */ + private providerProfileMutationReservation = 0 + /** + * Monotonic fence for profile mutations, bound ONLY at admission (when the + * queue actually invokes the operation). A successfully settled mutation + * with this generation supersedes stale handoff projection markers recorded + * by older admitted generations. Because the counter advances at admission, + * merely enqueuing a newer operation never supersedes an admitted older + * projection that is still in flight. + */ + private providerProfileMutationGeneration = 0 + /** Admitted generation of the last successfully settled (non-aborted) profile mutation. */ + private providerProfileMutationSettledGeneration = 0 + + /** + * In-memory marker for a post-commit provider handoff projection that + * failed. The committed child's task-local context remains authoritative; + * publication derives mode/profile/apiConfiguration from this marker so + * partial global writes cannot misreport the child. Never persisted. + */ + private staleProviderHandoffProjection?: { + childTaskId: string + requestedMode: string + apiConfigName: string | undefined + /** Explicit profile projection intent the stale projection was carrying. */ + profileIntent: ProviderHandoffProfileIntent + apiConfiguration: ProviderSettings + /** + * Admitted mutation generation the marker was recorded under + * (supersession fence). `undefined` marks a projection that was never + * admitted — it performed zero writes, so its marker is superseded by + * any later successful admitted mutation. Never used as a wildcard: + * relevance checks compare exact identity, never this value. + */ + generation: number | undefined + } + + /** + * In-flight background handoff projections keyed by the prepared child's + * task ID, holding the projection's immutable token (plus the admitted + * generation once the bounded queue admits it). Registered when the + * projection is initiated and dropped by + * {@link invalidateProviderHandoffProjectionState} when the child leaves + * the provider, so a deferred settlement can never pass the + * {@link isProviderHandoffProjectionStillRelevant} fence for a task that + * was removed, completed, abandoned, or deleted. + */ + private providerHandoffProjectionTargets?: Map + /** Source of immutable projection tokens; incremented synchronously per registration. */ + private nextProviderHandoffProjectionToken = 0 + + /** + * Children delegated with an explicit profile `clear` intent. While such a + * child is current and still carries no sticky profile, publication must + * show `undefined` instead of unconditionally falling back to the "default" + * profile identity — the absence is an explicit state, not a legacy unset. + * In-memory only; bounded by no-profile delegations in this session. + */ + private explicitProfileClearChildIds = new Set() + + /** + * Completion hook for the most recent background handoff projection. + * Deterministic test/observability access: awaiting this promise observes + * the projection outcome without polling or sleeps. + */ + private providerHandoffProjectionCompletion?: Promise - private runDelegationTransition(parentTaskId: string, fn: () => Promise): Promise { + /** + * Protocol bookkeeping for the delegation in flight, advanced at semantic + * landmarks by `delegateParentAndOpenChild`. Purely observational: the + * shared reducer never persists anything, never throws into the delegation + * flow, and cannot change rollback behavior. Tests read it to verify that + * ClineProvider walks the protocol in legal order. + */ + private providerHandoffProtocol?: ProviderHandoffTransaction + + /** + * The transition owner currently executing under each parent's delegation + * lock. The opaque token lets nested same-parent work (restoration/eviction + * reached while the lock is already held) prove its reentrancy and run the + * unlocked interruption core instead of deadlocking on its own lock. + * External callers hold no token and always acquire normally. + */ + private delegationTransitionOwners = new Map() + + private runDelegationTransition(parentTaskId: string, fn: (owner: symbol) => Promise): Promise { this.delegationTransitionLocks ??= new Map() - return runDelegationTransition(this.delegationTransitionLocks, parentTaskId, fn) + this.delegationTransitionOwners ??= new Map() + return runDelegationTransition(this.delegationTransitionLocks, parentTaskId, async () => { + const owner = Symbol(`delegation-transition:${parentTaskId}`) + this.delegationTransitionOwners.set(parentTaskId, owner) + try { + return await fn(owner) + } finally { + if (this.delegationTransitionOwners.get(parentTaskId) === owner) { + this.delegationTransitionOwners.delete(parentTaskId) + } + } + }) } - private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { + private enqueueProviderProfileMutation(fn: (signal: AbortSignal, generation: number) => Promise): Promise { + // Disposal fence: no new profile work is admitted after the provider + // began shutting down. + if (this._disposed) { + return Promise.reject(new Error("Provider profile mutation rejected: provider is disposed")) + } const controller = new AbortController() - // Run fn after either outcome so a rejected mutation never poisons the queue. - const run = this.providerProfileMutationQueue.then( - () => fn(controller.signal), - () => fn(controller.signal), - ) - const callerResult = this.withProviderProfileMutationTimeout(run, () => { - controller.abort() - this.log("Provider profile mutation timed out; aborting in-flight mutation") + // Reservation (enqueue order) — log identity only. The supersession + // generation is bound later, at admission, so a newer reservation that + // never starts cannot fence an admitted older mutation. + const reservation = ++this.providerProfileMutationReservation + // Registered so provider disposal can abort this operation whether it + // is still queued or already started; unregistered when it settles. + this.profileMutationAbortControllers.add(controller) + // Admission versus execution: `started` flips synchronously when the + // queue admits this operation (the previous tail settled and fn began). + // A timeout before admission is a cancellation — the signal aborts and + // the generic admission fence below rejects WITHOUT calling fn, so the + // abandoned callback performs zero writes no matter which caller + // enqueued it. Once fn has started, the queue tail REMAINS OWNED until + // the underlying operation settles: storage writes are not + // cancellable, so releasing the queue would let a newer write + // interleave with (or physically serialize behind) the still-running + // older one. + let started = false + // Bound when the queue admits the operation; `undefined` while it is + // still queued (cancelled-before-admission operations never bind one). + let admittedGeneration: number | undefined + const runAdmitted = (): Promise => { + // Generic admission fence, checked centrally for every caller: if + // this callback was aborted while still queued (caller timeout or + // provider disposal), it is never invoked at all. + if (controller.signal.aborted) { + return Promise.reject(new Error("Provider profile mutation cancelled before admission")) + } + started = true + // The generation is bound HERE, at admission: an operation that is + // cancelled before admission never consumes a generation, so the + // supersession fence only moves when a mutation actually starts. + admittedGeneration = ++this.providerProfileMutationGeneration + return fn(controller.signal, admittedGeneration) + } + const run = this.providerProfileMutationQueue.then(runAdmitted, runAdmitted) + const previousTail = this.providerProfileMutationQueue + let timeoutId: ReturnType | undefined + let fireTimeoutSignal!: () => void + // Non-rejecting timeout signal: the queue tail must observe the timeout + // even though the caller-facing promise below sees a rejection. + const timedOutSignal = new Promise((resolve) => { + fireTimeoutSignal = resolve + }) + const timedOut = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + // Abort first: every fn must check its signal before each write, + // so a timed-out-before-start operation performs no writes and + // its late completion cannot overwrite newer state. + controller.abort() + this.log( + `Provider profile mutation ${reservation} timed out; the caller is released and later admitted mutations supersede it` + + (started ? "; the queue stays owned until the started write settles" : ""), + ) + reject(new Error("Provider profile mutation timed out")) + fireTimeoutSignal() + }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + }) + // The caller-facing race consumes the timeout rejection, but a late fire + // after the caller already settled (or detached) must never surface as + // an unhandled rejection. + timedOut.catch(() => {}) + + const callerResult = Promise.race([run, timedOut]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } }) void run.then( () => { + this.profileMutationAbortControllers.delete(controller) + // Post-dispose completions are inert: no marker supersession or + // settled-generation bookkeeping once disposal began. + if (this._disposed) { + return + } if (controller.signal.aborted) { - this.log("Provider profile mutation completed after cancellation") + this.log(`Provider profile mutation ${reservation} completed after cancellation`) + return + } + // Admission-generation fence: a successful ADMITTED mutation + // supersedes any stale handoff projection marker recorded by an + // older admitted generation. A mutation cancelled before + // admission never settles here with a generation, so it can + // never supersede anything. + if (admittedGeneration === undefined) { + return } + this.providerProfileMutationSettledGeneration = admittedGeneration + this.supersedeStaleProviderHandoffProjection(admittedGeneration) }, (error) => { + this.profileMutationAbortControllers.delete(controller) if (controller.signal.aborted) { this.log( - `Provider profile mutation errored after cancellation: ${ + `Provider profile mutation ${reservation} errored after cancellation: ${ error instanceof Error ? error.message : String(error) }`, ) @@ -275,29 +495,294 @@ export class ClineProvider }, ) - // Advance from the timeout-bounded result. Each fn checks its AbortSignal before - // writing state, so advancing the queue on timeout cannot produce stale overwrites. - this.providerProfileMutationQueue = callerResult.then( + // Queue-tail ownership with an admission fence: the tail advances when + // the operation settles, or when the timeout fires BEFORE fn started + // (admission timeout — cancel-before-start, zero writes). If fn already + // started when the timeout fires, the tail remains owned by the + // in-flight underlying write: non-cancellable storage means later + // profile writes stay serialized behind it instead of overtaking it. + // An admission abort advances the tail to the PREVIOUS tail, so later + // operations still wait for every earlier started write. The caller + // timeout is a liveness guarantee for callers, not for the queue. + const settled = run.then( () => undefined, () => undefined, ) + this.providerProfileMutationQueue = Promise.race([ + settled, + timedOutSignal.then(() => (started ? settled : previousTail)), + ]) return callerResult } - private withProviderProfileMutationTimeout(operation: Promise, onTimeout: () => void): Promise { + /** + * Bounded disposal of the profile-mutation queue (provider shutdown): + * + * - every queued-but-not-started callback is cancelled at admission (its + * controller is aborted; the central admission fence ensures it never + * runs and performs zero writes); + * - started non-cancellable writes are awaited only until + * {@link PROFILE_MUTATION_DISPOSAL_DRAIN_TIMEOUT_MS}, then the queue is + * detached with handled promises — disposal is never unbounded; + * - post-dispose completions update no markers and emit no events. + */ + private async disposeProviderProfileMutationQueue(): Promise { + // Self-contained disposal fence: enqueues after this point are + // rejected and post-dispose completions become inert. (The provider's + // dispose() sets this flag earlier as well; setting it here keeps the + // queue-disposal contract true on its own.) + this._disposed = true + for (const controller of this.profileMutationAbortControllers) { + controller.abort() + } + this.profileMutationAbortControllers.clear() + + const drained = this.providerProfileMutationQueue.then( + () => undefined, + () => undefined, + ) let timeoutId: ReturnType | undefined - const timeout = new Promise((_, reject) => { + let detached = false + const deadline = new Promise((resolve) => { timeoutId = setTimeout(() => { - onTimeout() - reject(new Error("Provider profile mutation timed out")) - }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + detached = true + resolve() + }, ClineProvider.PROFILE_MUTATION_DISPOSAL_DRAIN_TIMEOUT_MS) }) + await Promise.race([ + drained.then(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } + }), + deadline, + ]) + if (detached) { + this.log( + `Provider disposal detached a still-running profile mutation after ${ClineProvider.PROFILE_MUTATION_DISPOSAL_DRAIN_TIMEOUT_MS}ms; its late completion is inert`, + ) + } + } - return Promise.race([operation, timeout]).finally(() => { - if (timeoutId) { - clearTimeout(timeoutId) + /** + * True when `generation` is still the newest ADMITTED profile mutation. + * Background projection results may update stale markers or emit events + * only while their admission generation is current; a superseded + * projection's completion is inert. Because generations are bound at + * admission, merely enqueuing a newer operation never makes an admitted + * in-flight projection non-current. + */ + private isCurrentProfileMutationGeneration(generation: number): boolean { + return generation === this.providerProfileMutationGeneration + } + + /** + * Allocate the immutable projection identity for a new background handoff + * projection and register it as the projection target for `childTaskId`. + * Synchronous by contract: the token exists before the bounded queue can + * admit or abandon the operation. A later registration for the same task + * ID atomically replaces the previous one — the replaced token never + * matches again. + */ + private registerProviderHandoffProjectionTarget(childTaskId: string): number { + const token = (this.nextProviderHandoffProjectionToken ?? 0) + 1 + this.nextProviderHandoffProjectionToken = token + this.providerHandoffProjectionTargets ??= new Map() + this.providerHandoffProjectionTargets.set(childTaskId, { token }) + return token + } + + /** + * Bind the admitted mutation generation to the registration owning exactly + * `token`. A no-op when the registration was replaced or removed: a stale + * projection can never re-target or overwrite a newer registration. + */ + private admitProviderHandoffProjectionTarget(childTaskId: string, token: number, generation: number): void { + const registered = this.providerHandoffProjectionTargets?.get(childTaskId) + if (registered?.token !== token) { + return + } + registered.admittedGeneration = generation + } + + /** + * Central relevance fence for every background handoff projection + * completion/failure path, checked before any stale-marker, explicit-clear, + * or event update. A settlement presenting `(childTaskId, token, + * admittedGeneration)` is relevant only while: + * + * 1. the provider is not disposed (post-disposal completions are inert); + * 2. the child's registered projection target still carries EXACTLY this + * immutable token — a removed registration, or one replaced by a newer + * projection for a reused task ID, never matches; and + * 3. after admission, the registered target still carries exactly this + * admitted generation and no newer mutation was admitted. There is no + * generation wildcard: an unadmitted settlement (`admittedGeneration === + * undefined`) is gated by exact token identity alone. + * + * A child that is removed, completed, abandoned, or deleted drops its + * registration via {@link invalidateProviderHandoffProjectionState} before + * its abort is awaited, so a deferred settlement that arrives afterwards is + * inert: it must never recreate stale/clear publication state for a task + * that is no longer the delegating child. + */ + private isProviderHandoffProjectionStillRelevant( + childTaskId: string, + token: number, + admittedGeneration?: number, + ): boolean { + if (this._disposed) { + return false + } + const registered = this.providerHandoffProjectionTargets?.get(childTaskId) + if (!registered || registered.token !== token) { + return false + } + if (admittedGeneration === undefined) { + return true + } + return ( + registered.admittedGeneration === admittedGeneration && + this.isCurrentProfileMutationGeneration(admittedGeneration) + ) + } + + /** + * Admission-generation fence for the stale handoff projection marker: any + * later successful ADMITTED mode/profile mutation supersedes a marker whose + * projection never ran (no admitted generation) or ran under an older + * generation, so publication can never overlay an outdated child snapshot + * after newer profile state was actually committed. + */ + private supersedeStaleProviderHandoffProjection(admittedGeneration: number): void { + const marker = this.staleProviderHandoffProjection + if (marker && (marker.generation === undefined || marker.generation < admittedGeneration)) { + this.staleProviderHandoffProjection = undefined + } + } + + /** + * True when `existing` is strictly newer than the marker about to be + * recorded and must be kept. An existing marker with an admitted generation + * outranks a never-admitted replacement (zero writes); among admitted + * generations the higher one wins. + */ + private isStaleMarkerNewerThan( + existing: { generation: number | undefined }, + admittedGeneration: number | undefined, + ): boolean { + if (admittedGeneration === undefined) { + return existing.generation !== undefined + } + return existing.generation !== undefined && existing.generation > admittedGeneration + } + + /** Record a stale handoff projection marker; never overwrites a newer generation's marker. */ + private markStaleProviderHandoffProjection( + childTaskId: string, + prepared: Readonly, + admittedGeneration: number | undefined, + ): void { + const existing = this.staleProviderHandoffProjection + if (existing && this.isStaleMarkerNewerThan(existing, admittedGeneration)) { + return + } + this.staleProviderHandoffProjection = { + childTaskId, + requestedMode: prepared.requestedMode, + apiConfigName: prepared.profile.name, + profileIntent: prepared.profile.intent, + apiConfiguration: structuredClone(prepared.apiConfiguration), + generation: admittedGeneration, + } + // An explicit no-profile handoff stays explicit even when its legacy + // projection could not complete: publication must show undefined for + // this child, never a defaulted profile identity. + if (prepared.profile.intent.kind === "clear") { + this.explicitProfileClearChildIds.add(childTaskId) + } + } + + /** Clear this generation's (or an older, or never-admitted) stale marker; a newer marker stays authoritative. */ + private clearStaleProviderHandoffProjection(admittedGeneration: number): void { + const marker = this.staleProviderHandoffProjection + if (marker && (marker.generation === undefined || marker.generation <= admittedGeneration)) { + this.staleProviderHandoffProjection = undefined + } + } + + /** + * True when an explicit profile `clear` is in force for the current task: + * either the current child was delegated with a no-profile intent and still + * carries no sticky profile, or a stale failed projection carrying a clear + * intent still fences publication for the current child. In both cases + * `getState`/`getStateToPostToWebview` must publish `undefined` instead of + * unconditionally falling back to the "default" identity; ordinary legacy + * behavior (no explicit clear) is unchanged. + */ + private async isExplicitProfileClearInForce(currentTaskId: string | undefined): Promise { + if (!currentTaskId) return false + if (this.explicitProfileClearChildIds.has(currentTaskId)) { + const currentTask = this.getCurrentTask() + if (currentTask?.taskId === currentTaskId && currentTask.taskApiConfigName !== undefined) { + // A later explicit profile choice on the child ends the clear. + this.explicitProfileClearChildIds.delete(currentTaskId) + return false } - }) + return true + } + // A stale clear-intent marker fences publication until a successful + // ADMITTED mutation supersedes it: the supersession fence clears the + // marker in place on every successful settlement, so its mere presence + // here means no admitted mutation has superseded it. + const marker = this.staleProviderHandoffProjection + if (marker?.childTaskId === currentTaskId && marker.profileIntent.kind === "clear") { + return true + } + // Durable reconstruction (provider reload): the in-memory sets above + // are empty after a reload, but an explicit clear durably removed the + // profile-store identity and the resumed child still carries no sticky + // profile. Reconstruct the clear from that durable state instead of + // falling back to the "default" identity. Only the still-current task + // is affected, and the read is best-effort: a failed read keeps the + // ordinary default fallback. Fresh installs carry the seeded "default" + // identity, so the legacy fallback there is unchanged. + const currentTask = this.getCurrentTask() + if (currentTask?.taskId !== currentTaskId || currentTask.taskApiConfigName !== undefined) { + return false + } + const durableIdentity = await this.providerSettingsManager + .getCurrentProfileName() + .catch(() => "unreadable" as const) + return durableIdentity === undefined + } + + /** + * The ONE idempotent terminal invalidation helper for a child task's + * in-memory handoff publication state. Every terminal path — stack + * removal/eviction, deletion (normal and `deleteTaskFromState` fallback), + * delegated completion, ordinary completion (only once the durable history + * update has established status `completed`), abandonment, and provider + * disposal — must call this exactly at the terminal commit boundary, + * synchronously before any await or state post. It drops, for this child only: + * + * 1. the explicit profile `clear` bookkeeping entry, + * 2. the background projection-target registration (the immutable token is + * gone, so any deferred settlement — including one already admitted and + * in flight — fails the {@link isProviderHandoffProjectionStillRelevant} + * fence and can never resurrect stale/clear state), and + * 3. a stale handoff projection marker recorded for this child. + * + * The call is safe to repeat and to call for unknown task IDs: every step + * is a bounded, side-effect-free removal. + */ + private invalidateProviderHandoffProjectionState(childTaskId: string): void { + this.explicitProfileClearChildIds.delete(childTaskId) + this.providerHandoffProjectionTargets?.delete(childTaskId) + const marker = this.staleProviderHandoffProjection + if (marker?.childTaskId === childTaskId) { + this.staleProviderHandoffProjection = undefined + } } private readonly pendingEditOperations: PendingEditOperationStore @@ -395,16 +880,31 @@ export class ClineProvider // saveClineMessages() omits the status field for top-level tasks, which causes // the store's merge to preserve a stale "interrupted" status after completion. // interrupted → completed is a valid VALID_TRANSITIONS path. + let completedDurably = false try { const existing = this.taskHistoryStore.get(taskId) if (existing && existing.status !== "completed") { await this.updateTaskHistory({ ...existing, status: "completed" }) } + // Terminal commit boundary: only a durable completed record for + // this exact task drops its in-memory handoff publication state. + // A rejected write or a missing record keeps the projection + // registration (and any marker/explicit-clear state) alive, so an + // in-flight deferred settlement stays relevant. + completedDurably = this.taskHistoryStore.get(taskId)?.status === "completed" } catch (err) { this.log( `[onTaskCompleted] Failed to write completed status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, ) } + if (completedDurably) { + // Synchronously before the completion/publication events: a + // TaskCompleted listener can publish state derived from the + // projection bookkeeping, and any deferred settlement that + // settles after this point must fail the relevance fence + // instead of resurrecting stale or explicit-clear state. + this.invalidateProviderHandoffProjectionState(taskId) + } this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) } const onTaskAborted = async () => { @@ -612,6 +1112,9 @@ export class ClineProvider // Remove the focused Cline instance from the stack. let task = this.taskRegistry.current if (task) { + // Terminal invalidation, synchronously before the abort is awaited: + // a removed task can never be the publication target again. + this.invalidateProviderHandoffProjectionState(task.taskId) task = this.taskRegistry.remove(task.taskId) } @@ -650,7 +1153,7 @@ export class ClineProvider * part of a delegation transition (i.e. everywhere except delegateParentAndOpenChild, * createTask with a parentTask, and reopenParentFromDelegation). */ - public async evictCurrentTask(): Promise { + public async evictCurrentTask(transitionOwner?: symbol): Promise { const current = this.getCurrentTask() const storedHistory = current ? this.taskHistoryStore.get(current.taskId) : undefined await this.removeClineFromStack() @@ -658,6 +1161,7 @@ export class ClineProvider await this.markDelegatedChildInterrupted({ childTaskId: storedHistory.id, parentTaskId: storedHistory.parentTaskId, + transitionOwner, }) } } @@ -674,9 +1178,46 @@ export class ClineProvider * Must be called AFTER removeClineFromStack() so the live Task's final saveClineMessages() * does not reattach the child's parentTaskId/rootTaskId over the interrupted status. */ + /** + * Locked wrapper for the interruption transition. When the caller already + * owns this parent's transition lock (an opaque owner token acquired from + * inside `runDelegationTransition` — restoration/eviction nesting), the + * unlocked core runs directly; the lock is never re-acquired for the same + * parent. Any other caller — including a different parent's transition — + * acquires the lock normally, so ordinary external eviction serialization + * is preserved. + */ private async markDelegatedChildInterrupted({ childTaskId, parentTaskId, + transitionOwner, + }: { + childTaskId: string + parentTaskId: string + transitionOwner?: symbol + }): Promise { + try { + if ( + transitionOwner !== undefined && + this.delegationTransitionOwners.get(parentTaskId) === transitionOwner + ) { + await this.markDelegatedChildInterruptedUnlocked({ childTaskId, parentTaskId }) + return + } + await this.runDelegationTransition(parentTaskId, () => + this.markDelegatedChildInterruptedUnlocked({ childTaskId, parentTaskId }), + ) + } catch (err) { + this.log( + `[markDelegatedChildInterrupted] Failed for child ${childTaskId}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + /** Unlocked interruption core; requires the parent transition lock (or its reentrant owner). */ + private async markDelegatedChildInterruptedUnlocked({ + childTaskId, + parentTaskId, }: { childTaskId: string parentTaskId: string @@ -688,7 +1229,7 @@ export class ClineProvider } try { - await this.runDelegationTransition(parentTaskId, async () => { + { const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) if (parentHistory?.status !== "delegated" || parentHistory?.awaitingChildId !== childTaskId) { @@ -722,7 +1263,7 @@ export class ClineProvider this.log( `[markDelegatedChildInterrupted] Marked child ${childTaskId} interrupted; parent ${parentTaskId} stays delegated`, ) - }) + } } catch (err) { this.log( `[markDelegatedChildInterrupted] Failed for child ${childTaskId}: ${err instanceof Error ? err.message : String(err)}`, @@ -837,6 +1378,17 @@ export class ClineProvider this._postStateToWebviewThrottled.cancel() this.log("Disposing ClineProvider...") + // Bounded disposal of queued/started profile mutations and background + // projections: queued callbacks are cancelled at admission (they never + // run), started writes are awaited only to a bounded deadline, and + // post-dispose completions update no markers and emit no events. + await this.disposeProviderProfileMutationQueue() + // Session-scoped explicit-clear markers, projection-target + // registrations, and stale markers do not survive the provider. + this.explicitProfileClearChildIds.clear() + this.providerHandoffProjectionTargets?.clear() + this.staleProviderHandoffProjection = undefined + // Reject any tasks still waiting for a scheduler permit so they don't // hold the event loop after the provider is torn down. this.taskScheduler.cancelQueued() @@ -1209,7 +1761,7 @@ export class ClineProvider public createTaskWithHistoryItem( historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, - options?: { startTask?: boolean }, + options?: { startTask?: boolean; transitionOwner?: symbol }, ): Promise { // History navigation can arrive concurrently (for example, two rapid // showTaskWithId messages). Serialize the full eviction/installation @@ -1230,7 +1782,7 @@ export class ClineProvider private async createTaskWithHistoryItemUnlocked( historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, - options?: { startTask?: boolean }, + options?: { startTask?: boolean; transitionOwner?: symbol }, ): Promise { const isCliRuntime = process.env.ROO_CLI_RUNTIME === "1" // CLI injects runtime provider settings from command flags/env at startup. @@ -1243,7 +1795,11 @@ export class ClineProvider const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id if (!isRehydratingCurrentTask) { - await this.evictCurrentTask() + // `transitionOwner` proves the caller already owns the evicted + // child's parent delegation transition (restoration under a held + // lock); same-parent interruption then runs its unlocked core + // instead of re-acquiring the lock it already holds. + await this.evictCurrentTask(options?.transitionOwner) } // If the history item has a saved mode, restore it and its associated API configuration. @@ -1764,83 +2320,95 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) + const previousMode = options.pendingHandoff ? this.getGlobalState("mode") : undefined - this.emit(RooCodeEventName.ModeChanged, newMode) + try { + await this.updateGlobalState("mode", newMode) - // If workspace lock is on, keep the current API config — don't load mode-specific config - const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) - if (lockApiConfigAcrossModes) { - await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => - this.postStateToWebview(), - ) - return - } + this.emit(RooCodeEventName.ModeChanged, newMode) - if (signal?.aborted) return + // If workspace lock is on, keep the current API config — don't load mode-specific config + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (lockApiConfigAcrossModes) { + await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => + this.postStateToWebview(), + ) + return + } - // Load the saved API config for the new mode if it exists. - const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) - const listApiConfig = await this.providerSettingsManager.listConfig() + if (signal?.aborted) return - if (signal?.aborted) return + // Load the saved API config for the new mode if it exists. + const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + const listApiConfig = await this.providerSettingsManager.listConfig() - // Update listApiConfigMeta first to ensure UI has latest data. - await this.updateGlobalState("listApiConfigMeta", listApiConfig) + if (signal?.aborted) return - // If this mode has a saved config, use it. - if (savedConfigId) { - const profile = listApiConfig.find(({ id }) => id === savedConfigId) + // Update listApiConfigMeta first to ensure UI has latest data. + await this.updateGlobalState("listApiConfigMeta", listApiConfig) - if (profile?.name) { - // Check if the profile has actual API configuration (not just an id). - // In CLI mode, the ProviderSettingsManager may return empty default profiles - // that only contain 'id' and 'name' fields. Activating such a profile would - // overwrite the CLI's working API configuration with empty settings. - // Skip activation if the profile has no apiProvider set - this indicates - // an unconfigured/empty profile. - const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) - const hasActualSettings = !!fullProfile.apiProvider - - if (hasActualSettings) { - const profileName = options.pendingHandoff - ? decideProviderHandoffProfile({ - locked: false, - savedProfile: { name: profile.name, id: profile.id }, - }).profile.name - : profile.name - const activationOptions = options.pendingHandoff - ? getProviderHandoffActivationOptions(options.pendingHandoff) - : targetTask === null - ? { skipCurrentTaskRebuild: true } - : undefined - await this.activateProviderProfileUnlocked({ name: profileName }, activationOptions, signal) + // If this mode has a saved config, use it. + if (savedConfigId) { + const profile = listApiConfig.find(({ id }) => id === savedConfigId) + + if (profile?.name) { + // Check if the profile has actual API configuration (not just an id). + // In CLI mode, the ProviderSettingsManager may return empty default profiles + // that only contain 'id' and 'name' fields. Activating such a profile would + // overwrite the CLI's working API configuration with empty settings. + // Skip activation if the profile has no apiProvider set - this indicates + // an unconfigured/empty profile. + const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) + const hasActualSettings = !!fullProfile.apiProvider + + if (hasActualSettings) { + const profileName = options.pendingHandoff + ? decideProviderHandoffProfile({ + locked: false, + savedProfile: { name: profile.name, id: profile.id }, + }).profile.name + : profile.name + const activationOptions = options.pendingHandoff + ? getProviderHandoffActivationOptions(options.pendingHandoff) + : targetTask === null + ? { skipCurrentTaskRebuild: true } + : undefined + await this.activateProviderProfileUnlocked({ name: profileName }, activationOptions, signal) + } else { + // The task will continue with the current/default configuration. + } } else { // The task will continue with the current/default configuration. } } else { - // The task will continue with the current/default configuration. + // If no saved config for this mode, save current config as default. + const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") + + const config = listApiConfig.find((candidate) => candidate.name === currentApiConfigNameAfter) + const configId = options.pendingHandoff + ? decideProviderHandoffProfile({ + locked: false, + currentProfile: currentApiConfigNameAfter + ? { name: currentApiConfigNameAfter, id: config?.id } + : undefined, + }).persistModeProfileId + : config?.id + + if (configId) { + await this.providerSettingsManager.setModeConfig(newMode, configId) + } } - } else { - // If no saved config for this mode, save current config as default. - const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") - - const config = listApiConfig.find((candidate) => candidate.name === currentApiConfigNameAfter) - const configId = options.pendingHandoff - ? decideProviderHandoffProfile({ - locked: false, - currentProfile: currentApiConfigNameAfter - ? { name: currentApiConfigNameAfter, id: config?.id } - : undefined, - }).persistModeProfileId - : config?.id - - if (configId) { - await this.providerSettingsManager.setModeConfig(newMode, configId) + + await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => + this.postStateToWebview(), + ) + } catch (error) { + if (options.pendingHandoff) { + await this.updateGlobalState("mode", previousMode) + if (previousMode !== undefined) this.emit(RooCodeEventName.ModeChanged, previousMode) } + throw error } - - await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => this.postStateToWebview()) } // Provider Profile Management @@ -2392,6 +2960,12 @@ export class ClineProvider // Delete all tasks from state in one batch await this.taskHistoryStore.deleteMany(allIdsToDelete) + // Terminal invalidation for every deleted id, immediately after the + // durable delete: a stale child id can never fence publication + // after deletion. + for (const taskId of allIdsToDelete) { + this.invalidateProviderHandoffProjectionState(taskId) + } this.recentTasksCache = undefined // Delete associated shadow repositories or branches and task directories @@ -2433,6 +3007,15 @@ export class ClineProvider } async deleteTaskFromState(id: string) { + // Terminal invalidation FIRST — synchronously, before any await or + // state post. This is the fallback delete path ("Task not found" in + // deleteTaskWithId): the durable delete below may reject and the post + // may never run, so the projection-target registration, explicit-clear + // bookkeeping, and stale marker must already be gone here. A deferred + // projection settlement that arrives during or after the delete then + // fails the exact-token relevance fence and can never resurrect stale + // or explicit-clear state for the deleted task. + this.invalidateProviderHandoffProjectionState(id) await this.taskHistoryStore.delete(id) this.recentTasksCache = undefined @@ -2763,7 +3346,7 @@ export class ClineProvider // Keep the default unauthenticated state if the optional Zoo Code auth service is unavailable. } - return { + const state: ExtensionState = { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, customInstructions, @@ -2814,7 +3397,9 @@ export class ClineProvider terminalZdotdir: terminalZdotdir ?? false, terminalProfile, mcpEnabled: mcpEnabled ?? true, - currentApiConfigName: currentApiConfigName ?? "default", + currentApiConfigName: + currentApiConfigName ?? + ((await this.isExplicitProfileClearInForce(currentTask?.taskId)) ? undefined : "default"), listApiConfigMeta: listApiConfigMeta ?? [], pinnedApiConfigs: pinnedApiConfigs ?? {}, mode: mode ?? defaultModeSlug, @@ -2920,6 +3505,35 @@ export class ClineProvider arch: process.arch, debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), } + + // A failed post-commit handoff projection leaves global state stale for + // the committed child. While that child is current, derive its execution + // fields from the child's authoritative task-local context so partial + // global writes cannot misreport its mode/profile/configuration. + const staleHandoffProjection = this.staleProviderHandoffProjection + if (staleHandoffProjection) { + // Supersession fence: any successful ADMITTED mode/profile mutation + // has already cleared an outdated marker in place, so a marker still + // present here is authoritative for this child. + if (currentTask?.taskId === staleHandoffProjection.childTaskId) { + state.mode = staleHandoffProjection.requestedMode + // The explicit intent decides the published identity: `set` + // names it, `clear` publishes the explicit absence (undefined, + // never the "default" fallback), `preserve` leaves the global + // identity untouched. + if (staleHandoffProjection.profileIntent.kind === "set") { + state.currentApiConfigName = staleHandoffProjection.profileIntent.name + } else if (staleHandoffProjection.profileIntent.kind === "clear") { + state.currentApiConfigName = undefined + } + state.apiConfiguration = structuredClone(staleHandoffProjection.apiConfiguration) + } else { + // The stale child is no longer current; the marker is obsolete. + this.staleProviderHandoffProjection = undefined + } + } + + return state } /** @@ -3051,7 +3665,11 @@ export class ClineProvider language: stateValues.language ?? formatLanguage(vscode.env.language), mcpEnabled: stateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", + // Preserve an explicit no-profile handoff for the current child: + // publish the absence instead of the legacy "default" fallback. + currentApiConfigName: + stateValues.currentApiConfigName ?? + ((await this.isExplicitProfileClearInForce(this.getCurrentTask()?.taskId)) ? undefined : "default"), listApiConfigMeta: stateValues.listApiConfigMeta ?? [], pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), @@ -3444,7 +4062,7 @@ export class ClineProvider text?: string, images?: string[], parentTask?: Task, - options: CreateTaskOptions = {}, + options: CreateTaskOptions & { handoffExecutionContext?: TaskHandoffExecutionContext } = {}, configuration: RooCodeSettings = {}, ): Promise { if (configuration) { @@ -3503,16 +4121,32 @@ export class ClineProvider }) } - if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { + // Handoff delegation passes an explicit, already-prepared execution + // context; it must be validated as all-or-none and used as-is instead + // of the global state values. Ordinary (non-handoff) initialization is + // unchanged: apiConfiguration comes from provider state as before. + const handoffExecutionContext = options.handoffExecutionContext + if (handoffExecutionContext !== undefined && !isCompleteTaskHandoffExecutionContext(handoffExecutionContext)) { + throw new Error( + "[createTask] handoffExecutionContext must be complete: mode, apiConfiguration, and apiConfigName are required together", + ) + } + const resolvedApiConfiguration = handoffExecutionContext?.apiConfiguration ?? apiConfiguration + + if (!ProfileValidator.isProfileAllowed(resolvedApiConfiguration, organizationAllowList)) { throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) } const task = new Task({ provider: this, - apiConfiguration, + apiConfiguration: resolvedApiConfiguration, enableCheckpoints, checkpointTimeout, - consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, + // One config source: every profile-derived constructor input — + // including the mistake limit the API handler guard enforces — must + // come from the SAME resolved configuration the child's API handler + // is built from, not from the pre-handoff global state. + consecutiveMistakeLimit: resolvedApiConfiguration.consecutiveMistakeLimit, task: text, images, experiments, @@ -3839,16 +4473,469 @@ export class ClineProvider return this.currentWorkspacePath || getWorkspacePath() } + /** + * Read-only provider handoff preparation. + * + * Captures everything the child will execute with — requested mode, profile + * decision (source/name/stable id), and a deep-cloned full API + * configuration including provider secret fields — while the delegating + * parent is still the current task. Read-only and deliberately off the + * provider profile mutation queue; performs zero writes to global state, + * the profile store, or any task. If this rejects, the caller aborts + * delegation before the parent is removed, leaving the parent current and + * every store unchanged. + */ + private async prepareProviderHandoffContext(requestedMode: Mode): Promise { + // Read-only preparation deliberately does NOT run on the provider + // profile mutation queue: a hung or timed-out underlying mutation must + // never block delegation preparation (queue liveness). Every read here + // is either a single-lock durable snapshot (ProviderSettingsManager + // locks its own store) or a synchronous ContextProxy read, and no write + // happens, so ordering against queued mutations is not required for + // safety: the prepared context is a point-in-time snapshot and later + // successful mutations supersede it through the generation fence. + const locked = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + const snapshot = await this.providerSettingsManager.snapshotForHandoff(requestedMode) + + const currentEntry = snapshot.currentApiConfigName + ? snapshot.entries.find((entry) => entry.name === snapshot.currentApiConfigName) + : undefined + const currentProfileRef = + snapshot.currentApiConfigName !== undefined + ? { name: snapshot.currentApiConfigName, id: currentEntry?.id } + : undefined + + // A saved mapping whose profile has no real provider settings is + // treated as unsaved: the child continues with the current + // configuration instead of activating an unconfigured profile. + const savedProfile = snapshot.savedProfile?.apiProvider ? snapshot.savedProfile : undefined + const hadSavedMapping = snapshot.modeApiConfigId !== undefined + + const decision = decideProviderHandoffProfile({ + locked, + currentProfile: currentProfileRef, + savedProfile: savedProfile ? { name: savedProfile.name, id: savedProfile.id } : undefined, + }) + + let apiConfiguration: ProviderSettings + if (savedProfile) { + const { name: _savedProfileName, id: _savedProfileId, ...profileSettings } = savedProfile + apiConfiguration = structuredClone(profileSettings) + } else { + apiConfiguration = structuredClone(this.contextProxy.getProviderSettings()) + } + + return createPreparedProviderHandoffContext({ + requestedMode, + profile: { source: decision.source, name: decision.profile?.name, id: decision.profile?.id }, + apiConfiguration, + // Persist the mode mapping post-commit for the saved profile + // (parity with the previous activation flow) and for a genuinely + // unsaved mode. A saved-but-unusable mapping is left untouched. + persistModeProfileId: + savedProfile?.id ?? + (hadSavedMapping + ? undefined + : decision.source === "unsaved-current" + ? decision.persistModeProfileId + : undefined), + }) + } + + /** + * Best-effort restoration of the parent when child creation fails after the + * parent was removed from the stack. Never masks the original error. + */ + private async restoreParentAfterFailedChildCreation( + parentTaskId: string, + transitionOwner?: symbol, + ): Promise { + try { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + await this.createTaskWithHistoryItem(parentHistory, { transitionOwner }) + return true + } catch (error) { + this.log( + `[delegateParentAndOpenChild] Failed to restore parent ${parentTaskId} after child creation failure: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + return false + } + } + + /** + * Authoritative reconciliation after a rejected delegation commit. + * + * At the atomic write boundary only parent history is guaranteed durable; + * the child's history record may legitimately be absent. The parent record + * is therefore re-read strictly from disk with {@link TaskHistoryStore.readFresh}, + * which — unlike the `invalidate`/`get` path — distinguishes a definitively + * missing record from one that exists but cannot be read or parsed. + * Callers run this while still holding the per-parent delegation transition + * lock and pass the commit-owned parent fields captured before the update + * attempt (the preimage). + * + * - `committed` (observation `exact`): the parent record is durably + * delegated to this attempted child. The child record is optional: a + * missing child history is expected; only a present record that + * contradicts the lineage degrades the observation. Nothing may be + * rolled back. + * - `incoherent` (observations `other-child` / `missing` / `unreadable`): + * the parent shows a delegation to a different child, is absent, or is + * unreadable — durability is unknowable, so no destructive rollback may + * run. + * - `uncommitted` (observation `unchanged`): the parent record exactly + * matches the safe nondelegated preimage on status, awaitingChildId, + * childIds, and pendingAction ownership — nothing persisted, so the + * rollback is safe. Any preimage mismatch degrades instead. + */ + private async reconcileDelegationCommitFailure( + parentTaskId: string, + childTaskId: string, + preimage: { + status: HistoryItem["status"] + awaitingChildId: HistoryItem["awaitingChildId"] + childIds: HistoryItem["childIds"] + pendingAction: HistoryItem["pendingAction"] + }, + ): Promise<{ + durability: "committed" | "uncommitted" | "incoherent" + observation: ProviderHandoffCommitObservation + errors: unknown[] + }> { + let parentRead: StrictTaskReadResult + try { + parentRead = await this.taskHistoryStore.readFresh(parentTaskId) + } catch (error) { + // A re-read failure must never trigger a destructive rollback. + return { durability: "incoherent", observation: "unreadable", errors: [error] } + } + + if (parentRead.kind === "error") { + return { durability: "incoherent", observation: "unreadable", errors: [parentRead.error] } + } + + if (parentRead.kind === "missing") { + return { durability: "incoherent", observation: "missing", errors: [] } + } + + const parent = parentRead.item + + // Exact delegated-to-attempted-child: committed regardless of whether + // the child's own history exists yet. + if (parent.status === "delegated" && parent.awaitingChildId === childTaskId) { + try { + const childRead = await this.taskHistoryStore.readFresh(childTaskId) + // Only a present child record that contradicts the lineage + // makes the observation incoherent; a missing or unreadable + // child history cannot contradict the authoritative parent. + if (childRead.kind === "found" && childRead.item.parentTaskId !== parentTaskId) { + return { durability: "incoherent", observation: "contradictory-child", errors: [] } + } + } catch (error) { + // A contradicting child record cannot be established from a + // failed read; the parent record alone stays authoritative. + void error + } + return { durability: "committed", observation: "exact", errors: [] } + } + + // Compare the commit-owned fields against the preimage captured before + // the update attempt. An exact match — nondelegated or still showing + // the pre-attempt delegation a re-delegation severed — proves this + // attempt persisted nothing, so the rollback is safe. + const unchanged = + parent.status === preimage.status && + parent.awaitingChildId === preimage.awaitingChildId && + JSON.stringify(parent.childIds ?? []) === JSON.stringify(preimage.childIds ?? []) && + parent.pendingAction?.actionId === preimage.pendingAction?.actionId + if (unchanged) { + return { durability: "uncommitted", observation: "unchanged", errors: [] } + } + + // A delegation to a different child that the preimage did not show must + // never be rolled back over. + if (parent.status === "delegated") { + return { durability: "incoherent", observation: "other-child", errors: [] } + } + + // The record drifted from the preimage in any other way: another writer + // moved it and durability is unknowable. + return { durability: "incoherent", observation: "drifted", errors: [] } + } + + /** + * Roll back a failed delegation after the parent was removed: close the + * paused child if it is still on top of the stack, delete the child, and + * restore the parent. Returns the errors of failed rollback steps so the + * caller can preserve the original failure while surfacing incomplete + * cleanup. + */ + private async rollbackFailedDelegation( + parentTaskId: string, + childTaskId: string, + transitionOwner?: symbol, + ): Promise<{ cleanupErrors: unknown[]; restorationErrors: unknown[] }> { + const cleanupErrors: unknown[] = [] + const restorationErrors: unknown[] = [] + + try { + // Only pop the stack if the child we just created is still on top. + // A concurrent delegation could have pushed another child since we created ours. + if (this.getCurrentTask()?.taskId === childTaskId) { + await this.removeClineFromStack() + } + } catch (error) { + cleanupErrors.push(error) + this.log( + `[delegateParentAndOpenChild] Failed to close paused child ${childTaskId} during rollback: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + try { + await this.deleteTaskWithId(childTaskId, false) + } catch (error) { + cleanupErrors.push(error) + this.log( + `[delegateParentAndOpenChild] Failed to delete paused child ${childTaskId} during rollback: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + try { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + await this.createTaskWithHistoryItem(parentHistory, { transitionOwner }) + } catch (error) { + restorationErrors.push(error) + this.log( + `[delegateParentAndOpenChild] Failed to restore parent ${parentTaskId} during rollback: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + return { cleanupErrors, restorationErrors } + } + + /** + * Named post-commit projection writes. Each operation reports its own + * outcome so boundary classification never depends on result ordering. + * Every write checks the abort fence before starting: after the bounded + * queue timeout no further write begins, so an abandoned operation cannot + * interleave writes with a later generation. + */ + private async runProviderHandoffProjectionWrites( + prepared: Readonly, + signal: AbortSignal, + ): Promise { + const namedStep = async ( + operation: ProviderHandoffProjectionOperation, + write: () => Promise, + ): Promise<{ result: NamedProviderHandoffProjectionResult; value?: T }> => { + if (signal.aborted) { + return { result: { operation, ok: false, error: new Error(`aborted before ${operation}`) } } + } + try { + return { result: { operation, ok: true }, value: await write() } + } catch (error) { + return { result: { operation, ok: false, error } } + } + } + + const intent = prepared.profile.intent + + // `preserve` performs no profile-identity write at all: the durable + // profile store and the legacy global identity are left untouched. + // `set` writes the prepared identity; `clear` writes the absence — + // undefined, never a skipped write. + const [mode, meta, providerSettings, profileStore] = await Promise.all([ + namedStep("global-mode", () => this.updateGlobalState("mode", prepared.requestedMode)), + namedStep("profile-meta-read", () => this.providerSettingsManager.listConfig()), + namedStep("provider-settings", () => + this.contextProxy.setProviderSettings(structuredClone(prepared.apiConfiguration)), + ), + intent.kind === "preserve" + ? Promise.resolve({ result: { operation: "profile-store" as const, ok: true } }) + : namedStep("profile-store", () => + this.providerSettingsManager.projectHandoffState({ + intent, + mode: prepared.requestedMode, + modeConfigId: prepared.persistModeProfileId, + }), + ), + ]) + + const results: NamedProviderHandoffProjectionResult[] = [ + mode.result, + meta.result, + providerSettings.result, + profileStore.result, + ] + + // Dependent writes run only when the read that feeds them succeeded. + const listConfig = meta.result.ok ? meta.value : undefined + if (listConfig !== undefined) { + results.push( + (await namedStep("global-config-meta", () => this.updateGlobalState("listApiConfigMeta", listConfig))) + .result, + ) + } + if (intent.kind === "set") { + results.push( + ( + await namedStep("global-profile-name", () => + this.updateGlobalState("currentApiConfigName", intent.name), + ) + ).result, + ) + } else if (intent.kind === "clear") { + // Explicit clear: write undefined so legacy global state stops + // claiming a profile identity the child does not have. + results.push( + ( + await namedStep("global-profile-name", () => + this.updateGlobalState("currentApiConfigName", undefined), + ) + ).result, + ) + } + + return results + } + + /** + * Best-effort post-commit projection of the prepared handoff context onto + * legacy global state and the durable profile store. Runs strictly AFTER + * the durable delegation commit, as fire-and-forget background work: it can + * never undo the commit, never blocks the per-parent delegation lock, and + * never delays the child start. The queue batch is bounded for the caller — + * an admission timeout abandons it before any write; once a write has + * started the queue stays owned until the non-cancellable storage write + * settles. A failed/abandoned projection stamps the generation-fenced stale + * marker, superseded by any later successful ADMITTED mode/profile mutation. + * Every settlement is additionally gated by + * {@link isProviderHandoffProjectionStillRelevant} on the projection's + * immutable token identity: once the prepared child leaves the provider + * (removed, completed, abandoned, or deleted) — or its task ID is reused by + * a newer projection — or the provider disposes, completion is inert and + * never recreates stale or explicit-clear state. + */ + private async projectPreparedProviderHandoffState( + prepared: Readonly, + childTaskId: string, + ): Promise { + // Allocate the immutable projection identity and register the target + // synchronously, before the bounded queue can admit or abandon the + // operation: a child that leaves the provider drops this registration + // (via invalidateProviderHandoffProjectionState), and a newer + // registration for a reused task ID replaces this token, so a deferred + // settlement can never resurrect stale/clear state for it. + const projectionToken = this.registerProviderHandoffProjectionTarget(childTaskId) + // Bound at admission; stays undefined when the bounded queue abandons + // the operation before it runs (zero writes — the marker it stamps, if + // any, carries no admitted generation and is superseded by any later + // successful admitted mutation). + let admittedGeneration: number | undefined + try { + return await this.enqueueProviderProfileMutation(async (signal, generation) => { + admittedGeneration = generation + // Bind the admitted generation to this projection's exact token + // for the relevance fence below. A no-op if the registration was + // already replaced or removed. + this.admitProviderHandoffProjectionTarget(childTaskId, projectionToken, generation) + // Cancel-before-start: the queue admitted the operation after + // its own timeout fired. Perform zero writes. + if (signal.aborted) { + return { ok: false, boundary: "queue" } + } + const results = await this.runProviderHandoffProjectionWrites(prepared, signal) + // The bounded queue may already have abandoned this operation; a + // late completion stays inert (its outcome is discarded by the + // caller and must not clear the marker or emit events). + if (signal.aborted) { + return { ok: false, boundary: "queue" } + } + const outcome = classifyProviderHandoffProjectionResults(results) + // Central relevance fence: marker updates, explicit-clear state, + // and events apply only while the provider is live, the prepared + // child is still the registered target for this exact token, and + // — after admission — the registration still carries exactly this + // admitted generation with no newer mutation admitted. A + // superseded or orphaned settlement is inert. + if (!this.isProviderHandoffProjectionStillRelevant(childTaskId, projectionToken, generation)) { + return outcome.ok ? { ok: true } : outcome + } + if (!outcome.ok) { + this.markStaleProviderHandoffProjection(childTaskId, prepared, generation) + // Log a stable boundary/category only: provider-originated + // error text is never interpolated (even redacted), so + // arbitrary remote strings cannot reach the log. The raw + // error stays on the named result for in-memory callers and + // is never persisted or logged here. + const failure = results.find((result) => !result.ok) + const failureCategory = failure?.error instanceof Error ? failure.error.name : typeof failure?.error + this.log( + `[delegateParentAndOpenChild] Post-commit handoff projection failed for child ${childTaskId} ` + + `at ${outcome.failedOperation} (${failureCategory}); continuing with child-local values`, + ) + return outcome + } + this.clearStaleProviderHandoffProjection(generation) + // Preserve the external mode-change signal the previous + // pre-removal switch emitted, now strictly after the durable + // commit. Never emitted by an operation the queue abandoned, + // whose generation was already superseded, that completed after + // the provider began disposing, or whose child already left. + try { + this.emit(RooCodeEventName.ModeChanged, prepared.requestedMode) + } catch { + // non-fatal + } + return { ok: true } + }) + } catch { + // The queued projection was abandoned (bounded timeout or provider + // disposal) before its writes completed. The durable delegation and + // the child's authoritative task-local context are unaffected; the + // stale marker makes publication derive child values until a later + // successful admitted mutation supersedes it. The abandonment is + // logged as a stable boundary only, without error detail. + // Bookkeeping stays behind the exact-token relevance fence: a child + // that already left the provider, a task ID reused by a newer + // projection, or a disposed provider is never re-marked. + if (!this.isProviderHandoffProjectionStillRelevant(childTaskId, projectionToken, admittedGeneration)) { + return { ok: false, boundary: "queue" } + } + this.markStaleProviderHandoffProjection(childTaskId, prepared, admittedGeneration) + this.log( + `[delegateParentAndOpenChild] Post-commit handoff projection abandoned for child ${childTaskId}; ` + + `continuing with child-local values`, + ) + return { ok: false, boundary: "queue" } + } + } + /** * Delegate parent task and open child task. * * - Enforce single-open invariant - * - Persist parent delegation metadata + * - Read-only prepare the child's execution context while the parent is + * still current (no global/profile/event/publication writes) + * - Persist parent delegation metadata atomically * - Emit TaskDelegated (task-level; API forwards to provider/bridge) - * - Create child as sole active and switch mode to child's mode - * - Fail closed if the mode-switch handoff rejects: the parent is never - * removed from the stack, so it stays the current, active task and no - * child is created or scheduled + * - Create the paused child from the explicit prepared context, make that + * context authoritative on the child, then project legacy global state + * - Fail closed if preparation rejects: the parent is never removed from + * the stack, so it stays the current, active task and no child is + * created or scheduled. If child creation or the atomic commit fails, + * the child is cleaned up and the parent is restored. + * - Advance the shared provider-handoff protocol at each semantic + * landmark. The reducer is observational bookkeeping only: it never + * persists, never throws into this flow, and never drives rollback. */ public async delegateParentAndOpenChild(params: { parentTaskId: string @@ -3857,6 +4944,30 @@ export class ClineProvider mode: string pendingActionId?: string }): Promise { + const { parentTaskId } = params + // Full per-parent transition serialization: validation, read-only + // preparation, parent removal, child creation, commit, reconciliation, + // rollback, activation, projection, and child start all run inside the + // same runDelegationTransition lock used by completion and abandonment. + // Two same-parent delegations (or a completion/abandonment racing a + // delegation) can therefore never interleave; later callers observe the + // committed/delegated state when the lock releases. The wrapper delegates + // to the unlocked implementation to avoid recursive lock acquisition. + return this.runDelegationTransition(parentTaskId, (owner) => + this.delegateParentAndOpenChildUnlocked(params, owner), + ) + } + + private async delegateParentAndOpenChildUnlocked( + params: { + parentTaskId: string + message: string + initialTodos: TodoItem[] + mode: string + pendingActionId?: string + }, + transitionOwner: symbol, + ): Promise { const { parentTaskId, message, initialTodos, mode, pendingActionId } = params // Metadata-driven delegation is always enabled @@ -3913,21 +5024,30 @@ export class ClineProvider ) } - // 3) Switch provider mode to child's requested mode BEFORE disposing the parent. - // This is a null-target, non-publishing handoff (see - // PRODUCTION_PROVIDER_HANDOFF_POLICY): it applies only global mode/profile - // state and never mutates or publishes the current task, so running it while - // the parent is still focused is safe. Performing it first makes delegation - // fail closed: if the mode switch rejects, we abort before the parent is - // removed from the stack, so the parent remains the current, active task and - // no child is created or scheduled. - // The mode switch must also happen before createTask() because the Task - // constructor initializes its mode from provider.getState() during - // initializeTaskMode(). + // 3) Read-only handoff preparation while the parent is still the current + // task. This replaces the old mutating pre-removal mode switch: no + // global/profile/event/publication write happens before the durable + // delegation commit. If preparation rejects, delegation aborts with the + // parent still current and every store untouched (fail closed). The + // explicit prepared context also removes the ordering dependency on + // createTask(): the child no longer infers its mode from + // provider.getState() during initializeTaskMode(). const handoff = createProviderHandoffPlan(mode) - await this.handleModeSwitch(handoff.requestedMode, handoff.policy.targetTask, { - pendingHandoff: handoff.policy, - }) + // Protocol bookkeeping only: the shared reducer records semantic + // landmarks, rejects none of the steps below in a correct run, and can + // neither persist anything nor alter rollback behavior. + const handoffProtocol = createProviderHandoffTransaction() + this.providerHandoffProtocol = handoffProtocol + let prepared: PreparedProviderHandoffContext + try { + prepared = await this.prepareProviderHandoffContext(handoff.requestedMode) + } catch (error) { + // Fail-closed abort landmark: the parent was never removed and no + // store was touched, so the protocol terminal is a clean abort. + handoffProtocol.advance({ type: "prepare-failed" }) + throw error + } + handoffProtocol.advance({ type: "prepare" }) // 4) Enforce single-open invariant by closing/disposing the parent first // This ensures we never have >1 tasks open at any time during delegation. @@ -3942,6 +5062,7 @@ export class ClineProvider ) // Non-fatal: proceed with child creation even if parent cleanup had issues } + handoffProtocol.advance({ type: "remove-parent" }) // 5) Create child as sole active (parent reference preserved for lineage) // Pass initialStatus: "active" to ensure the child task's historyItem is created @@ -3954,11 +5075,31 @@ export class ClineProvider // Without this, the child's fire-and-forget startTask() races with step 5, // and the last writer to globalState overwrites the other's changes— // causing the parent's delegation fields to be lost. - const child = await this.createTask(message, undefined, parent as any, { - initialTodos, - initialStatus: "active", - startTask: false, - }) + let child: Task + try { + child = await this.createTask(message, undefined, parent, { + initialTodos, + initialStatus: "active", + startTask: false, + // All-or-none explicit handoff-only execution context: the child + // must not asynchronously infer its mode/profile from mutable + // global state. Completeness is runtime-validated by createTask + // and the Task constructor. + handoffExecutionContext: { + mode: prepared.requestedMode, + apiConfigName: prepared.profile.name, + apiConfiguration: structuredClone(prepared.apiConfiguration), + }, + }) + } catch (error) { + // Child creation failed after the parent was removed: restore the + // parent, leave the child absent, and rethrow the original error. + const restored = await this.restoreParentAfterFailedChildCreation(parentTaskId, transitionOwner) + handoffProtocol.advance({ type: "create-child-failed" }) + handoffProtocol.advance({ type: "rollback-restore", ok: restored }) + throw error + } + handoffProtocol.advance({ type: "create-child" }) // 6) Persist parent delegation metadata BEFORE the child starts writing. // atomicReadAndUpdate reads from the in-memory cache and writes back within a @@ -3973,6 +5114,17 @@ export class ClineProvider // synchronously under the store lock) so a concurrent abandon or completion cannot // slip between the status snapshot and the write. An active child must never be // silently detached. + // Commit-owned parent fields captured before the update attempt. After + // a rejected commit, the strict fresh re-read compares against this + // preimage: an exact match proves the write never persisted; any + // mismatch means another writer moved the record. + const preCommitParent = this.taskHistoryStore.get(parentTaskId) + const commitPreimage = { + status: preCommitParent?.status, + awaitingChildId: preCommitParent?.awaitingChildId, + childIds: preCommitParent?.childIds, + pendingAction: preCommitParent?.pendingAction, + } try { await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => { if (pendingActionId && historyItem.pendingAction?.actionId !== pendingActionId) { @@ -3990,58 +5142,146 @@ export class ClineProvider delegated.pendingAction?.actionId === pendingActionId ? undefined : delegated.pendingAction, } }) - this.recentTasksCache = undefined - if (this.isViewLaunched) { - const updatedItem = this.taskHistoryStore.get(parentTaskId) - if (updatedItem) { - await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) - } - } + handoffProtocol.advance({ type: "commit-delegation" }) } catch (err) { + handoffProtocol.advance({ type: "commit-failed" }) this.log( `[delegateParentAndOpenChild] Failed to persist parent metadata for ${parentTaskId} -> ${child.taskId}: ${ (err as Error)?.message ?? String(err) }`, ) - try { - // Only pop the stack if the child we just created is still on top. - // A concurrent delegation could have pushed another child since we created ours. - if (this.getCurrentTask()?.taskId === child.taskId) { - await this.removeClineFromStack() - } - } catch (cleanupError) { + // Authoritative reconciliation while still under the per-parent + // transition serialization: strictly re-read the parent record from + // disk (child history is optional at this boundary) and resolve the + // commit's ambiguous durability before any destructive action. + const reconciliation = await this.reconcileDelegationCommitFailure( + parentTaskId, + child.taskId, + commitPreimage, + ) + if (reconciliation.durability === "committed") { + // The rejected write actually persisted. The delegation is + // durable: do NOT delete the child or restore the parent over + // committed lineage — treat the handoff as committed and + // continue with context activation below. + handoffProtocol.advance({ + type: "observe-commit-durability", + durability: "committed", + observation: reconciliation.observation, + }) this.log( - `[delegateParentAndOpenChild] Failed to close paused child ${child.taskId} during rollback: ${ - (cleanupError as Error)?.message ?? String(cleanupError) - }`, + `[delegateParentAndOpenChild] Commit for ${parentTaskId} -> ${child.taskId} rejected after persisting; ` + + `keeping the durable delegation and continuing`, ) - } - try { - await this.deleteTaskWithId(child.taskId, false) - } catch (cleanupError) { - this.log( - `[delegateParentAndOpenChild] Failed to delete paused child ${child.taskId} during rollback: ${ - (cleanupError as Error)?.message ?? String(cleanupError) - }`, + } else if (reconciliation.durability === "uncommitted") { + handoffProtocol.advance({ + type: "observe-commit-durability", + durability: "uncommitted", + observation: reconciliation.observation, + }) + const rollback = await this.rollbackFailedDelegation(parentTaskId, child.taskId, transitionOwner) + handoffProtocol.advance({ type: "rollback-cleanup", ok: rollback.cleanupErrors.length === 0 }) + handoffProtocol.advance({ type: "rollback-restore", ok: rollback.restorationErrors.length === 0 }) + if (rollback.cleanupErrors.length + rollback.restorationErrors.length > 0) { + // Preserve the original failure while surfacing incomplete + // cleanup; the original error is first in the aggregate. + throw new AggregateError( + [err, ...rollback.cleanupErrors, ...rollback.restorationErrors], + `[delegateParentAndOpenChild] Delegation rollback incomplete for parent ${parentTaskId}; original error: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + throw err + } else { + // Incoherent records or failed re-read: never roll back + // destructively over potentially committed lineage. Keep the + // child paused, keep the parent record untouched, and surface + // the ambiguity with the original error retained. + handoffProtocol.advance({ + type: "observe-commit-durability", + durability: "incoherent", + observation: reconciliation.observation, + }) + throw new AggregateError( + [err, ...reconciliation.errors], + `[delegateParentAndOpenChild] Delegation commit durability could not be determined for parent ${parentTaskId} -> child ${child.taskId}; ` + + `the child is left paused and the parent record untouched`, ) } + } + + // Post-commit webview publication is best-effort: a publication error + // must never roll back the durable delegation or prevent the child + // from starting. + this.recentTasksCache = undefined + if (this.isViewLaunched) { try { - const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) - await this.createTaskWithHistoryItem(parentHistory) - } catch (rollbackError) { + const updatedItem = this.taskHistoryStore.get(parentTaskId) + if (updatedItem) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) + } + } catch (error) { this.log( - `[delegateParentAndOpenChild] Failed to restore parent ${parentTaskId} during rollback: ${ - (rollbackError as Error)?.message ?? String(rollbackError) + `[delegateParentAndOpenChild] Failed to publish delegation update for ${parentTaskId}: ${ + error instanceof Error ? error.message : String(error) }`, ) } - throw err } - // 7) Start the child task now that parent metadata is safely persisted. + // 7) Synchronously make the prepared context authoritative on the child. + // The child is still paused; from here on its task-local mode, sticky + // profile, and apiConfiguration are authoritative no matter what the + // legacy global projection does. + child.adoptHandoffExecutionContext({ + mode: prepared.requestedMode, + apiConfigName: prepared.profile.name, + apiConfiguration: structuredClone(prepared.apiConfiguration), + }) + handoffProtocol.advance({ type: "activate-context" }) + + // An explicit no-profile handoff keeps its explicit-clear publication + // state for this child regardless of how the background projection ends. + if (prepared.profile.intent.kind === "clear") { + this.explicitProfileClearChildIds.add(child.taskId) + } + + // 8) Start the child task immediately: the durable delegation is + // committed and the child's execution context is authoritative, so + // the child must never await the legacy projection. scheduleTask(this.taskScheduler, child, "delegateParentAndOpenChild") + handoffProtocol.advance({ type: "start-child" }) + + // 9) Best-effort legacy projection of the prepared context onto global + // state and the durable profile store — fire-and-forget background + // work OUTSIDE the per-parent delegation lock and OUTSIDE the + // child-start critical path. The promise is handled (never a floating + // rejection): it logs, updates generation-fenced bookkeeping, and + // records the protocol landmark when still relevant. Tests await the + // exposed completion hook deterministically instead of sleeping. + const projectionCompletion = this.projectPreparedProviderHandoffState(prepared, child.taskId) + .then((outcome) => { + handoffProtocol.advance({ + type: "project-legacy", + boundary: outcome.boundary ?? "context-proxy", + ok: outcome.ok, + }) + return outcome + }) + .catch(() => { + // Stable boundary only: provider-originated error text is never + // interpolated into the log. + this.log( + `[delegateParentAndOpenChild] Background handoff projection rejected for child ${child.taskId}; ` + + `continuing with child-local values`, + ) + return { ok: false, boundary: "queue" as const } + }) + this.providerHandoffProjectionCompletion = projectionCompletion + void projectionCompletion - // 8) Emit TaskDelegated (provider-level) + // 10) Emit TaskDelegated (provider-level) try { this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) } catch { @@ -4061,7 +5301,7 @@ export class ClineProvider pendingActionId?: string }): Promise { const { parentTaskId, childTaskId, completionResultSummary, pendingActionId } = params - return this.runDelegationTransition(parentTaskId, async () => { + return this.runDelegationTransition(parentTaskId, async (transitionOwner) => { const globalStoragePath = this.contextProxy.globalStorageUri.fsPath // 1) Load parent from history and current persisted messages @@ -4278,6 +5518,18 @@ export class ClineProvider ) this.recentTasksCache = undefined + // Terminal invalidation at the EXACT durable commit boundary — the + // child is completed as of the atomic pair write above and can never + // be the publication target again. It runs synchronously before any + // further await, so a parent reconstruction/resume failure below + // cannot retain the child's projection-target registration: a + // deferred projection settlement arriving afterwards fails the + // exact-token relevance fence instead of resurrecting stale or + // explicit-clear state for the completed child. (Nothing is cleared + // before this point: on an aborted pre-commit path the child is + // still active and its in-flight projection remains meaningful.) + this.invalidateProviderHandoffProjectionState(childTaskId) + // Notify the webview of both updated items so its in-memory history stays current. if (this.isViewLaunched) { const updatedChild = this.taskHistoryStore.get(childTaskId) @@ -4299,7 +5551,15 @@ export class ClineProvider // 7) Reopen the parent from history as the sole active task (restores saved mode) // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling - const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) + // The transition owner proves this restoration already holds the + // parent's transition lock, so a same-parent interruption triggered + // by the eviction inside runs its unlocked core instead of + // deadlocking on the lock we hold. Other parents still acquire + // their own locks normally. + const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { + startTask: false, + transitionOwner, + }) // 8) Inject restored histories into the in-memory instance before resuming if (parentInstance) { @@ -4326,6 +5586,8 @@ export class ClineProvider } this.cancelledDelegationChildIds.delete(childTaskId) + // The child's publication markers were already invalidated at the + // durable commit boundary above. return true }) } @@ -4412,6 +5674,12 @@ export class ClineProvider // not the live task's readonly parentTaskId field, so this is the authoritative gate. this.cancelledDelegationChildIds.add(childTaskId) + // Terminal invalidation at the durable sever boundary: the link is + // severed and the child left the provider, so its projection-target + // registration and explicit-clear publication markers are dropped + // synchronously before the state post below. + this.invalidateProviderHandoffProjectionState(childTaskId) + if (this.isViewLaunched) { const updatedChild = this.taskHistoryStore.get(childTaskId) const updatedParent = this.taskHistoryStore.get(parentTaskId) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index dc7e8e1617..d934a888bd 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -102,6 +102,20 @@ vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { }) vi.mock("../../task/Task", () => ({ + isCompleteTaskHandoffExecutionContext: (execution: unknown) => { + const candidate = execution as + | { mode?: unknown; apiConfigName?: unknown; apiConfiguration?: unknown } + | undefined + return ( + candidate !== undefined && + typeof candidate === "object" && + typeof candidate.mode === "string" && + candidate.mode.length > 0 && + typeof candidate.apiConfigName === "string" && + candidate.apiConfigName.length > 0 && + candidate.apiConfiguration !== undefined + ) + }, Task: vi.fn().mockImplementation(function (options) { const mockTask = { api: undefined, @@ -114,9 +128,18 @@ vi.mock("../../task/Task", () => ({ taskId: options?.historyItem?.id || "test-task-id", emit: vi.fn(), setTaskApiConfigName: vi.fn(), - updateApiConfiguration: vi.fn().mockImplementation(function (this: any, newConfig: any) { + updateApiConfiguration: vi.fn().mockImplementation(function ( + this: { apiConfiguration?: unknown }, + newConfig: unknown, + ) { this.apiConfiguration = newConfig }), + adoptHandoffExecutionContext: vi.fn().mockImplementation(function ( + this: { apiConfiguration?: unknown }, + execution: { mode: string; apiConfigName?: string; apiConfiguration: unknown }, + ) { + this.apiConfiguration = execution.apiConfiguration + }), } // Define apiConfiguration as a property so tests can read it Object.defineProperty(mockTask, "apiConfiguration", { @@ -236,6 +259,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Mock providerSettingsManager ;(provider as any).providerSettingsManager = { saveConfig: vi.fn().mockResolvedValue("test-id"), + // Durable identity present by default: the explicit-clear + // reconstruction fallback must not engage in these tests. + getCurrentProfileName: vi.fn().mockResolvedValue("test-config"), listConfig: vi.fn().mockResolvedValue([ { name: "test-config", @@ -258,6 +284,20 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }), + snapshotForHandoff: vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + entries: [ + { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + modelId: "openai/gpt-4", + }, + ], + modeApiConfigId: undefined, + savedProfile: undefined, + }), + projectHandoffState: vi.fn().mockResolvedValue(undefined), } // Get the buildApiHandler mock @@ -482,7 +522,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") }) - test("timed-out mutations abort before writing state and advance the queue", async () => { + test("timed-out mutations abort before writing state and release the caller", async () => { vi.useFakeTimers() const logSpy = vi.spyOn(provider, "log") const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") @@ -527,7 +567,13 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Aborted first activation wrote nothing; only second profile is set. expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "first-profile") expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") - expect(logSpy).toHaveBeenCalledWith("Provider profile mutation timed out; aborting in-flight mutation") + // The timeout released the caller while the started write stayed + // owned by the queue; the log records the fenced release. + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + "timed out; the caller is released and later admitted mutations supersede it", + ), + ) } finally { vi.useRealTimers() } @@ -660,6 +706,33 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(postStateSpy).not.toHaveBeenCalled() }) + test("pending child preparation restores the previous mode when profile lookup rejects", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode + await provider.addClineToStack(unrelatedTask) + await provider.contextProxy.setValue("mode", "code") + const lookupError = new Error("profile lookup failed") + provider["providerSettingsManager"].getModeConfigId = vi.fn().mockRejectedValue(lookupError) + const emitSpy = vi.spyOn(provider, "emit") + vi.mocked(mockContext.globalState.update).mockClear() + + await expect( + provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }), + ).rejects.toThrow(lookupError) + + const modeWrites = vi.mocked(mockContext.globalState.update).mock.calls.filter(([key]) => key === "mode") + expect(modeWrites).toEqual([ + ["mode", "ask"], + ["mode", "code"], + ]) + expect(provider.contextProxy.getValue("mode")).toBe("code") + expect(unrelatedTask["_taskMode"]).toBe("code") + expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "ask") + expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "code") + }) + test("pending child preparation tolerates a current profile missing from configuration metadata", async () => { const unrelatedTask = new Task(defaultTaskOptions) await provider.addClineToStack(unrelatedTask) @@ -952,14 +1025,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const rootClineMessagesBefore = rootTask.clineMessages const rootApiHistoryBefore = rootTask.apiConversationHistory - // Spy without replacing the implementation: the pending handoff must not + // Spy without replacing the implementation: the handoff must not // publish any state. const postStateSpy = vi.spyOn(provider, "postStateToWebview") - // Exercise the real handleModeSwitch/handleModeSwitchUnlocked path with - // profile activation for the child's mode. - provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("test-id") - const childResult = await provider.delegateParentAndOpenChild({ parentTaskId: "parent-task-id", message: "Do child work", @@ -975,12 +1044,32 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { initialTodos: [], initialStatus: "active", startTask: false, + handoffExecutionContext: { + mode: "ask", + apiConfigName: "test-config", + apiConfiguration: expect.anything(), + }, }) expect(atomicUpdateSpy).toHaveBeenCalledTimes(1) - // The real mode switch applied the child's mode globally without a single - // state publication during the entire delegation. + // The prepared context became authoritative on the paused child after + // the durable commit. + expect(child.adoptHandoffExecutionContext).toHaveBeenCalledWith({ + mode: "ask", + apiConfigName: "test-config", + apiConfiguration: expect.anything(), + }) + + // The mode/profile projection is a post-commit legacy write: it happens + // only after the atomic delegation commit, and no state is published at + // any point during the delegation. expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + const globalUpdateMock = vi.mocked(mockContext.globalState.update) + const modeWriteIndex = globalUpdateMock.mock.calls.findIndex(([key]) => key === "mode") + expect(modeWriteIndex).toBeGreaterThanOrEqual(0) + expect(globalUpdateMock.mock.invocationCallOrder[modeWriteIndex]).toBeGreaterThan( + atomicUpdateSpy.mock.invocationCallOrder[0], + ) expect(postStateSpy).not.toHaveBeenCalled() // The stack transitioned parent -> child through the real addClineToStack, @@ -1001,6 +1090,306 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) }) + describe("delegateParentAndOpenChild - provider handoff transaction", () => { + /** Sole-parent topology for transaction-level assertions. */ + async function setupSoleParentDelegation() { + const parentTask = new Task(defaultTaskOptions) + Object.defineProperty(parentTask, "taskId", { value: "parent-task-id" }) + parentTask["_taskMode"] = "code" as Mode + Object.defineProperty(parentTask, "flushPendingToolResultsToHistory", { + value: vi.fn().mockResolvedValue(true), + }) + await provider.addClineToStack(parentTask) + await provider.contextProxy.setValue("mode", "code") + + const child = new Task({ ...defaultTaskOptions }) + Object.defineProperty(child, "taskId", { value: "child-task-id" }) + child["_taskMode"] = "code" as Mode + Object.defineProperty(child, "run", { value: vi.fn().mockResolvedValue(undefined) }) + + const parentHistory: HistoryItem = { + id: "parent-task-id", + number: 1, + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + mode: "code", + childIds: [], + } + const atomicUpdateSpy = vi + .spyOn(provider.taskHistoryStore, "atomicReadAndUpdate") + .mockImplementation(async (_taskId: string, updater: (current: HistoryItem) => HistoryItem) => [ + updater(parentHistory), + ]) + const createTaskSpy = vi.spyOn(provider, "createTask").mockImplementation(async () => { + await provider.addClineToStack(child) + return child + }) + + return { parentTask, child, atomicUpdateSpy, createTaskSpy } + } + + test("preparation failure keeps the parent current with every store unchanged and publishes nothing", async () => { + const { parentTask, atomicUpdateSpy, createTaskSpy } = await setupSoleParentDelegation() + + const preparationError = new Error("profile snapshot failed") + provider["providerSettingsManager"].snapshotForHandoff = vi.fn().mockRejectedValue(preparationError) + const postStateSpy = vi.spyOn(provider, "postStateToWebview") + const emitSpy = vi.spyOn(provider, "emit") + vi.mocked(mockContext.globalState.update).mockClear() + vi.mocked(mockContext.secrets.store).mockClear() + + await expect( + provider.delegateParentAndOpenChild({ + parentTaskId: "parent-task-id", + message: "Do child work", + initialTodos: [], + mode: "ask", + }), + ).rejects.toThrow(preparationError) + + // Fail closed: the parent was never removed. + expect(provider.getCurrentTask()).toBe(parentTask) + expect(createTaskSpy).not.toHaveBeenCalled() + expect(atomicUpdateSpy).not.toHaveBeenCalled() + + // No store was touched and nothing was published. + expect(mockContext.globalState.update).not.toHaveBeenCalled() + expect(mockContext.secrets.store).not.toHaveBeenCalled() + expect(provider["providerSettingsManager"].projectHandoffState).not.toHaveBeenCalled() + expect(postStateSpy).not.toHaveBeenCalled() + expect(emitSpy).not.toHaveBeenCalledWith( + RooCodeEventName.TaskDelegated, + "parent-task-id", + expect.anything(), + ) + }) + + test("a saved profile passes the full configuration with its sentinel secret to the child", async () => { + const { child, atomicUpdateSpy, createTaskSpy } = await setupSoleParentDelegation() + + provider["providerSettingsManager"].snapshotForHandoff = vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + entries: [{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }], + modeApiConfigId: "ask-id", + savedProfile: { + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "sk-handoff-sentinel-987654", + }, + }) + vi.mocked(mockContext.globalState.update).mockClear() + + await provider.delegateParentAndOpenChild({ + parentTaskId: "parent-task-id", + message: "Do child work", + initialTodos: [], + mode: "ask", + }) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + const creationOptions = createTaskSpy.mock.calls[0]?.[3] + if (!creationOptions) { + throw new Error("expected createTask to have been called") + } + expect(creationOptions).toMatchObject({ + handoffExecutionContext: { + mode: "ask", + apiConfigName: "ask-profile", + }, + }) + // The full saved profile data — including the provider secret field — + // reaches the child's construction configuration. + expect(creationOptions.handoffExecutionContext?.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "sk-handoff-sentinel-987654", + }) + + // The prepared context is authoritative on the child after the commit. + expect(child.adoptHandoffExecutionContext).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "ask", + apiConfigName: "ask-profile", + apiConfiguration: expect.objectContaining({ openRouterApiKey: "sk-handoff-sentinel-987654" }), + }), + ) + + // Post-commit legacy projections only: current profile and durable mode + // mapping are written after the atomic commit, never before it. + expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") + expect(provider["providerSettingsManager"].projectHandoffState).toHaveBeenCalledWith({ + intent: { kind: "set", name: "ask-profile" }, + mode: "ask", + modeConfigId: "ask-id", + }) + const globalUpdateMock = vi.mocked(mockContext.globalState.update) + const modeWriteIndex = globalUpdateMock.mock.calls.findIndex(([key]) => key === "mode") + expect(modeWriteIndex).toBeGreaterThanOrEqual(0) + expect(globalUpdateMock.mock.invocationCallOrder[modeWriteIndex]).toBeGreaterThan( + atomicUpdateSpy.mock.invocationCallOrder[0], + ) + }) + + test("the locked profile keeps the current configuration and persists no mode mapping", async () => { + const { createTaskSpy } = await setupSoleParentDelegation() + + vi.mocked(mockContext.workspaceState.get).mockReturnValue(true) + vi.mocked(mockContext.globalState.update).mockClear() + + await provider.delegateParentAndOpenChild({ + parentTaskId: "parent-task-id", + message: "Do child work", + initialTodos: [], + mode: "ask", + }) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + const creationOptions = createTaskSpy.mock.calls[0]?.[3] + if (!creationOptions) { + throw new Error("expected createTask to have been called") + } + expect(creationOptions).toMatchObject({ + handoffExecutionContext: { + mode: "ask", + apiConfigName: "test-config", + }, + }) + // Locked: the child continues with the current context configuration. + expect(creationOptions.handoffExecutionContext?.apiConfiguration).toEqual( + provider.contextProxy.getProviderSettings(), + ) + + // A locked handoff carries an explicit preserve intent: no profile + // write at all — and with the pin engaged there is no mode mapping + // to persist either, so the durable store is never touched. + expect(provider["providerSettingsManager"].projectHandoffState).not.toHaveBeenCalled() + expect(provider["providerSettingsManager"].snapshotForHandoff).toHaveBeenCalledWith("ask") + }) + + test("a ContextProxy projection failure keeps the committed child current and publication derives child values", async () => { + const { child } = await setupSoleParentDelegation() + + const projectionError = new Error("context write failed") + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(projectionError) + const logSpy = vi.spyOn(provider, "log") + + await provider.delegateParentAndOpenChild({ + parentTaskId: "parent-task-id", + message: "Do child work", + initialTodos: [], + mode: "ask", + }) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The projection failed at the ContextProxy boundary... + expect(setValueSpy).toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Post-commit handoff projection failed")) + + // ...but the committed child remains current, started, and authoritative. + expect(provider.getCurrentTask()).toBe(child) + expect(child.run).toHaveBeenCalledTimes(1) + const staleMarker = provider["staleProviderHandoffProjection"] + expect(staleMarker).toMatchObject({ childTaskId: "child-task-id", requestedMode: "ask" }) + + // Publication derives the child's execution fields from the prepared + // context instead of the stale partial global state. + const state = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + expect(state.mode).toBe("ask") + expect(state.currentApiConfigName).toBe("test-config") + expect(state.apiConfiguration).toEqual(staleMarker?.apiConfiguration) + }) + + test("a later successful same-child mode mutation supersedes the stale projection marker", async () => { + const { child } = await setupSoleParentDelegation() + + const projectionError = new Error("context write failed") + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(projectionError) + + await provider.delegateParentAndOpenChild({ + parentTaskId: "parent-task-id", + message: "Do child work", + initialTodos: [], + mode: "ask", + }) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The failed projection left a stale marker and publication overlays it. + expect(provider["staleProviderHandoffProjection"]).toMatchObject({ childTaskId: "child-task-id" }) + const stateBefore = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + expect(stateBefore.mode).toBe("ask") + + // The user switches the child's mode: the mutation runs on the same + // bounded queue and succeeds, so it supersedes the older marker. + await provider["enqueueProviderProfileMutation"].call(provider, async () => { + await provider.contextProxy.setValue("mode", "code") + }) + + expect(provider["staleProviderHandoffProjection"]).toBeUndefined() + // Publication returns the new values, never the stale snapshot. + const stateAfter = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + expect(stateAfter.mode).toBe("code") + expect(stateAfter.currentApiConfigName).toBe("test-config") + expect(setValueSpy).toHaveBeenCalledWith("mode", "code") + expect(child.run).toHaveBeenCalledTimes(1) + }) + + test("a profile-store projection failure is logged redacted and never undoes the delegation", async () => { + const { child } = await setupSoleParentDelegation() + + const sentinel = "sk-handoff-sentinel-246810" + provider["providerSettingsManager"].snapshotForHandoff = vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + entries: [{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }], + modeApiConfigId: "ask-id", + savedProfile: { + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: sentinel, + }, + }) + const projectionError = new Error(`durable store rejected ${sentinel}`) + provider["providerSettingsManager"].projectHandoffState = vi.fn().mockRejectedValue(projectionError) + const logSpy = vi.spyOn(provider, "log") + + await provider.delegateParentAndOpenChild({ + parentTaskId: "parent-task-id", + message: "Do child work", + initialTodos: [], + mode: "ask", + }) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Delegation stays committed; the child started with the exact snapshot. + expect(provider.getCurrentTask()).toBe(child) + expect(child.run).toHaveBeenCalledTimes(1) + expect(child.adoptHandoffExecutionContext).toHaveBeenCalledWith( + expect.objectContaining({ apiConfiguration: expect.objectContaining({ openRouterApiKey: sentinel }) }), + ) + + // The failure is logged redacted: no secret value appears in any log. + const logged = logSpy.mock.calls.map((call) => call.join(" ")).join("\n") + expect(logged).toContain("Post-commit handoff projection failed") + expect(logged).not.toContain(sentinel) + + // Publication reports the child's saved profile despite the stale global projection. + const state = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + expect(state.mode).toBe("ask") + expect(state.currentApiConfigName).toBe("ask-profile") + }) + }) + describe("profile switching sequence", () => { test("A -> B -> A updates task.apiConfiguration each time", async () => { const mockTask = new Task({ @@ -1070,4 +1459,47 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(getModelId({})).toBeUndefined() }) }) + + describe("createTask - one configuration source", () => { + test("derives the mistake limit from the resolved handoff configuration, not global state", async () => { + // The pre-handoff global configuration carries a different limit than + // the prepared handoff profile. + await provider.contextProxy.setValue("consecutiveMistakeLimit", 7) + vi.mocked(Task).mockClear() + + await provider.createTask("Child work", undefined, undefined, { + startTask: false, + handoffExecutionContext: { + mode: "code", + apiConfigName: "handoff-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + consecutiveMistakeLimit: 3, + }, + }, + }) + + // The API handler is built from the resolved handoff configuration; + // every profile-derived constructor input must come from the SAME + // source, so the child is constructed with the handoff profile's + // limit — never the stale global one. + const constructorOptions = vi.mocked(Task).mock.calls.at(-1)?.[0] + expect(constructorOptions?.apiConfiguration).toMatchObject({ consecutiveMistakeLimit: 3 }) + expect(constructorOptions?.consecutiveMistakeLimit).toBe(3) + }) + + test("ordinary (non-handoff) tasks still derive the limit from global state", async () => { + await provider.contextProxy.setValue("consecutiveMistakeLimit", 7) + vi.mocked(Task).mockClear() + + await provider.createTask("Ordinary work", undefined, undefined, { startTask: false }) + + // Control: without a handoff context the global configuration is the + // source of truth, unchanged from previous behavior. + const constructorOptions = vi.mocked(Task).mock.calls.at(-1)?.[0] + expect(constructorOptions?.apiConfiguration).toMatchObject({ consecutiveMistakeLimit: 7 }) + expect(constructorOptions?.consecutiveMistakeLimit).toBe(7) + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a6a82a5b0..80fd4031d0 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1776,6 +1776,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue("test-id"), listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), @@ -1797,6 +1798,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() @@ -1826,6 +1828,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), @@ -1853,6 +1856,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), @@ -2023,6 +2027,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), listConfig: vi .fn() .mockResolvedValue([ @@ -2373,6 +2378,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue("saved-config-id"), listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), @@ -2397,6 +2403,7 @@ describe("ClineProvider", () => { test("saves current config when switching to mode without config", async () => { ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() @@ -2459,6 +2466,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -2523,6 +2531,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue("config-id"), listConfig: vi .fn() @@ -2587,6 +2596,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -2618,6 +2628,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -2662,6 +2673,7 @@ describe("ClineProvider", () => { // Mock provider settings manager to throw error ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), getModeConfigId: vi.fn().mockResolvedValue("config-id"), listConfig: vi .fn() @@ -2764,6 +2776,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), setModeConfig: vi.fn().mockRejectedValue(new Error("Failed to update mode config")), listConfig: vi .fn() @@ -2797,6 +2810,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi @@ -2842,6 +2856,7 @@ describe("ClineProvider", () => { throw new Error("API handler error") }) ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi @@ -2885,6 +2900,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi @@ -5010,6 +5026,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), listConfig: vi.fn().mockResolvedValue([]), } @@ -5041,6 +5058,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const saveConfig = vi.fn().mockResolvedValue(undefined) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), listConfig: vi.fn().mockResolvedValue([ { name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }, { name: "Backup Zoo", apiProvider: providerIdentifiers.zooGateway }, @@ -5082,6 +5100,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.spyOn(provider, "getState").mockRejectedValue(new Error("state unavailable")) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), listConfig: vi.fn().mockResolvedValue([]), } @@ -5100,6 +5119,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), listConfig: vi.fn(), } @@ -5115,6 +5135,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), listConfig: vi .fn() .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), @@ -5136,6 +5157,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), listConfig: vi .fn() .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), @@ -5156,6 +5178,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + getCurrentProfileName: vi.fn().mockResolvedValue("default"), listConfig: vi .fn() .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 0365283222..53c26d6557 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -3,10 +3,12 @@ import * as vscode from "vscode" import type { HistoryItem, ExtensionMessage } from "@roo-code/types" import { RooCodeEventName } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { TelemetryService } from "@roo-code/telemetry" import { ContextProxy } from "../../config/ContextProxy" import { ClineProvider } from "../ClineProvider" +import { createPreparedProviderHandoffContext } from "../../task-persistence/providerHandoff" // Mock setup vi.mock("p-wait-for", () => ({ @@ -856,5 +858,135 @@ describe("ClineProvider Task History Synchronization", () => { expect(listener).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith("child-task", {}, {}) }) + + /** + * Register the real taskCreationCallback listeners on a fake task. The + * callback is typed for the real Task class; the fake task double only + * implements the event surface the callback touches. + */ + function registerFakeTask(fakeTask: ReturnType) { + const registerTaskEventListeners = provider["taskCreationCallback"] as (task: object) => void + registerTaskEventListeners(fakeTask) + } + + /** Prepared clear-intent handoff whose deferred projection writes fail at the profile-meta read. */ + function makeClearIntentPreparedHandoff() { + return createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "unsaved-current", name: undefined, id: undefined }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + }) + } + + it("invalidates handoff projection state immediately after durable completion and before the TaskCompleted event; a late failed settlement cannot resurrect it", async () => { + const taskId = "task-cb-handoff-1" + await provider.updateTaskHistory(createHistoryItem({ id: taskId, task: "T" }), { broadcast: false }) + + // A deferred handoff projection is admitted and in flight on a gated + // profile-store write; its profile-meta read fails, so an UNFENCED + // settlement would stamp a stale marker and an explicit clear. + vi.spyOn(provider.providerSettingsManager, "listConfig").mockRejectedValue(new Error("listConfig failed")) + let releaseProjection!: () => void + const writeGate = new Promise((resolve) => { + releaseProjection = resolve + }) + vi.spyOn(provider.providerSettingsManager, "projectHandoffState").mockReturnValue(writeGate) + const projection = provider["projectPreparedProviderHandoffState"](makeClearIntentPreparedHandoff(), taskId) + + // Let the bounded queue admit the projection: it binds its token and + // admitted generation, then hangs on the gated write. + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + const registration = provider["providerHandoffProjectionTargets"]?.get(taskId) + expect(registration).toMatchObject({ token: expect.any(Number), admittedGeneration: expect.any(Number) }) + + // Observe ordering: the completed write lands before the completion + // event, and the projection registration is already dropped when the + // event fires. + const order: string[] = [] + const updateTaskHistoryOriginal = provider.updateTaskHistory.bind(provider) + vi.spyOn(provider, "updateTaskHistory").mockImplementation(async (item, options) => { + const history = await updateTaskHistoryOriginal(item, options) + order.push("persisted-completed") + return history + }) + // A real TaskCompleted listener observes provider state at + // event-delivery time, which is what publication consumers see. + const emitSpy = vi.spyOn(provider, "emit") + let registrationAtTaskCompletedEvent: unknown = "not-emitted" + provider.on(RooCodeEventName.TaskCompleted, () => { + registrationAtTaskCompletedEvent = provider["providerHandoffProjectionTargets"]?.get(taskId) + order.push("emit-TaskCompleted") + }) + + const fakeTask = makeFakeTask(taskId) + registerFakeTask(fakeTask) + await fakeTask.emit(RooCodeEventName.TaskCompleted, taskId, {}, {}) + + expect(provider.taskHistoryStore.get(taskId)?.status).toBe("completed") + expect(order).toEqual(["persisted-completed", "emit-TaskCompleted"]) + expect(registrationAtTaskCompletedEvent).toBeUndefined() + expect(provider["providerHandoffProjectionTargets"]?.has(taskId)).toBe(false) + + // The deferred projection then fails. Its settlement is fenced by + // the dropped registration: no stale marker, no explicit-clear + // state, no publication event, and no failure log can resurrect. + const logSpy = vi.spyOn(provider, "log") + releaseProjection() + const outcome = await projection + expect(outcome.ok).toBe(false) + expect(provider["staleProviderHandoffProjection"]).toBeUndefined() + expect(provider["explicitProfileClearChildIds"].has(taskId)).toBe(false) + expect(provider["providerHandoffProjectionTargets"]?.has(taskId)).toBe(false) + expect(emitSpy).not.toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "code") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Post-commit handoff projection")) + }) + + it("keeps the deferred settlement relevant when the completed write is not durable", async () => { + const taskId = "task-cb-handoff-2" + await provider.updateTaskHistory(createHistoryItem({ id: taskId, task: "T" }), { broadcast: false }) + + vi.spyOn(provider.providerSettingsManager, "listConfig").mockRejectedValue(new Error("listConfig failed")) + let releaseProjection!: () => void + const writeGate = new Promise((resolve) => { + releaseProjection = resolve + }) + vi.spyOn(provider.providerSettingsManager, "projectHandoffState").mockReturnValue(writeGate) + const projection = provider["projectPreparedProviderHandoffState"](makeClearIntentPreparedHandoff(), taskId) + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + const registration = provider["providerHandoffProjectionTargets"]?.get(taskId) + expect(registration).toMatchObject({ token: expect.any(Number), admittedGeneration: expect.any(Number) }) + + // Persistence rejects: no durable completed record is established, + // so the projection registration must survive the completion event. + const logSpy = vi.spyOn(provider, "log") + vi.spyOn(provider, "updateTaskHistory").mockRejectedValueOnce(new Error("disk full")) + const fakeTask = makeFakeTask(taskId) + registerFakeTask(fakeTask) + await fakeTask.emit(RooCodeEventName.TaskCompleted, taskId, {}, {}) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[onTaskCompleted] Failed to write")) + expect(provider["providerHandoffProjectionTargets"]?.get(taskId)).toMatchObject({ + token: registration?.token, + admittedGeneration: registration?.admittedGeneration, + }) + + // The deferred projection then fails — and because completion was + // not durable, the settlement is still relevant: marker, explicit + // clear, and failure log all land. This control proves the + // invalidation in the previous test is what fences them. + releaseProjection() + const outcome = await projection + expect(outcome.ok).toBe(false) + expect(provider["staleProviderHandoffProjection"]).toMatchObject({ + childTaskId: taskId, + profileIntent: { kind: "clear" }, + }) + expect(provider["explicitProfileClearChildIds"].has(taskId)).toBe(true) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Post-commit handoff projection failed")) + }) }) }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 09d1c67a78..a75a361c13 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -46,7 +46,7 @@ }, "__tests__/ClineProvider.delegation.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 9 + "count": 2 } }, "activate/CodeActionProvider.ts": { @@ -1026,12 +1026,12 @@ }, "core/webview/ClineProvider.ts": { "@typescript-eslint/no-explicit-any": { - "count": 8 + "count": 6 } }, "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 34 + "count": 32 } }, "core/webview/__tests__/ClineProvider.spec.ts": { @@ -1716,7 +1716,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 2 } }, "utils/tts.ts": { diff --git a/src/utils/advisoryFileLock.ts b/src/utils/advisoryFileLock.ts new file mode 100644 index 0000000000..52f454ea5f --- /dev/null +++ b/src/utils/advisoryFileLock.ts @@ -0,0 +1,95 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as lockfile from "proper-lockfile" + +/** + * How long a proper-lockfile lock may appear unrefreshed before it is treated + * as stale by other processes. Writers refresh every 10s, so 31s leaves + * headroom while still recovering from a crashed holder. + */ +export const LOCK_STALE_MS = 31_000 + +/** Retry configuration shape accepted by proper-lockfile's `retries` option. */ +export interface AdvisoryFileLockRetryOptions { + retries: number + factor?: number + minTimeout?: number + maxTimeout?: number +} + +/** + * Default acquisition-retry budget, identical to the one safeWriteJson has + * always used: a holder typically finishes in well under this window. + */ +const ADVISORY_LOCK_DEFAULT_RETRIES: AdvisoryFileLockRetryOptions = { + retries: 5, + factor: 2, + minTimeout: 100, + maxTimeout: 1000, +} + +/** + * Acquisition-retry budget for read-under-lock paths. Bounded, but long + * enough to wait out an in-flight cross-process write (including its + * temp/backup rename window) instead of misreading the file mid-write. + */ +export const ADVISORY_READ_LOCK_RETRIES: AdvisoryFileLockRetryOptions = { + retries: 15, + factor: 1.5, + minTimeout: 100, + maxTimeout: 500, +} + +/** + * Acquire the same inter-process advisory `proper-lockfile` lock that + * `safeWriteJson` uses for `filePath`, run `fn` while holding it, and always + * release the lock afterwards. This is the single lock-configuration owner: + * writers and read-under-lock callers share it, so a reader can never observe + * a write's temp-file rename gap and a writer can never race a strict reader. + * + * Lock-acquisition failures propagate to the caller (the lock was never + * held). A failed release is logged and does not mask `fn`'s outcome, + * matching safeWriteJson's historical release behavior. + */ +export async function withAdvisoryFileLock( + filePath: string, + fn: () => Promise, + options?: { retries?: AdvisoryFileLockRetryOptions }, +): Promise { + const absoluteFilePath = path.resolve(filePath) + + // proper-lockfile stores its lock beside the target path, so the + // containing directory must exist before acquisition (safeWriteJson has + // always ensured this up front; idempotent for readers). + await fs.mkdir(path.dirname(absoluteFilePath), { recursive: true }) + + let releaseLock: () => Promise + try { + releaseLock = await lockfile.lock(absoluteFilePath, { + stale: LOCK_STALE_MS, + update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long + realpath: false, // the file may not exist yet, which is acceptable + retries: options?.retries ?? ADVISORY_LOCK_DEFAULT_RETRIES, + onCompromised: (err) => { + console.error(`Lock at ${absoluteFilePath} was compromised:`, err) + throw err + }, + }) + } catch (lockError) { + // If lock acquisition fails, the lock was never held; propagate. + console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) + throw lockError + } + + try { + return await fn() + } finally { + try { + await releaseLock() + } catch (unlockError) { + // Do not re-throw here: a failed unlock must never mask the + // outcome of the work done under the lock. + console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) + } + } +} diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 957a0bb20f..57e0bd48e9 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -1,9 +1,10 @@ import * as fs from "fs/promises" import * as fsSync from "fs" import * as path from "path" -import * as lockfile from "proper-lockfile" import { JsonStreamStringify } from "json-stream-stringify" +import { withAdvisoryFileLock } from "./advisoryFileLock" + /** * Options for safeWriteJson function */ @@ -42,9 +43,8 @@ export interface SafeWriteJsonOptions { * @returns {Promise} */ -async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { +async function safeWriteJson(filePath: string, data: unknown, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) - let releaseLock = async () => {} // Initialized to a no-op // For directory creation const dirPath = path.dirname(absoluteFilePath) @@ -61,33 +61,24 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } - // Acquire the lock before any file operations - try { - releaseLock = await lockfile.lock(absoluteFilePath, { - stale: LOCK_STALE_MS, - update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long - realpath: false, // the file may not exist yet, which is acceptable - retries: { - // Configuration for retrying lock acquisition - retries: 5, // Number of retries after the initial attempt - factor: 2, // Exponential backoff factor (e.g., 100ms, 200ms, 400ms, ...) - minTimeout: 100, // Minimum time to wait before the first retry (in ms) - maxTimeout: 1000, // Maximum time to wait for any single retry (in ms) - }, - onCompromised: (err) => { - console.error(`Lock at ${absoluteFilePath} was compromised:`, err) - throw err - }, - }) - } catch (lockError) { - // If lock acquisition fails, we throw immediately. - // The releaseLock remains a no-op, so the finally block in the main file operations - // try-catch-finally won't try to release an unacquired lock if this path is taken. - console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) - // Propagate the lock acquisition error - throw lockError - } + // Acquire the shared advisory lock (the same proper-lockfile lock that + // read-under-lock callers take) before any file operations, then perform + // the temp/backup/commit sequence while holding it. Acquisition failures + // propagate; the release-failure and rollback behavior of the critical + // section is unchanged. + return withAdvisoryFileLock(absoluteFilePath, () => safeWriteJsonUnderLock(absoluteFilePath, data, options)) +} +/** + * The temp-file, backup, rename-commit, and rollback sequence, run while + * holding the advisory file lock. Lock acquisition/release is owned by + * {@link withAdvisoryFileLock}. + */ +async function safeWriteJsonUnderLock( + absoluteFilePath: string, + data: unknown, + options?: SafeWriteJsonOptions, +): Promise { // Variables to hold the actual paths of temp files if they are created. let actualTempNewFilePath: string | null = null let actualTempBackupFilePath: string | null = null @@ -206,16 +197,6 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } } throw originalError // This MUST be the error that rejects the promise. - } finally { - // Release the lock in the main finally block. - try { - // releaseLock will be the actual unlock function if lock was acquired, - // or the initial no-op if acquisition failed. - await releaseLock() - } catch (unlockError) { - // Do not re-throw here, as the originalError from the try/catch (if any) is more important. - console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) - } } } @@ -226,7 +207,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso * @param prettyPrint Whether to format the JSON with indentation. * @returns Promise */ -async function _streamDataToFile(targetPath: string, data: any, prettyPrint = false): Promise { +async function _streamDataToFile(targetPath: string, data: unknown, prettyPrint = false): Promise { // Stream data to avoid high memory usage for large JSON objects. const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8" }) @@ -247,6 +228,11 @@ async function _streamDataToFile(targetPath: string, data: any, prettyPrint = fa }) } -export const LOCK_STALE_MS = 31_000 +export { + LOCK_STALE_MS, + withAdvisoryFileLock, + ADVISORY_READ_LOCK_RETRIES, + type AdvisoryFileLockRetryOptions, +} from "./advisoryFileLock" export { safeWriteJson } From 366307d3cc64fe00d093da6f05bb06d18cbeb475 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Mon, 7 Sep 2026 15:09:06 +0000 Subject: [PATCH 08/17] fix(delegation): release transition lock before parent resume --- .../history-resume-delegation.spec.ts | 43 +++++++++++++++++++ src/core/webview/ClineProvider.ts | 26 +++++++---- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 2fea1a3183..8634ab7f42 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -303,6 +303,49 @@ describe("History resume delegation - parent metadata transitions", () => { ) }) + it("releases the parent transition lock before resuming so the parent can delegate again", async () => { + const parentHistoryItem = { + id: "parent-sequential", + status: "delegated", + awaitingChildId: "child-first", + ts: Date.now(), + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const childHistoryItem = { id: "child-first", status: "active" } + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const parentInstance = { + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + } + + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), + getCurrentTask: vi.fn(() => ({ taskId: "child-first" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), + taskHistoryStore, + emit: vi.fn(), + }) + parentInstance.resumeAfterDelegation.mockImplementation(async () => { + await provider["runDelegationTransition"]("parent-sequential", async () => undefined) + }) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-sequential", + childTaskId: "child-first", + completionResultSummary: "First child done", + }), + ).resolves.toBe(true) + + expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) + }) + it("reopenParentFromDelegation invalidates the child's projection state at the durable commit boundary so a later reconstruction failure cannot retain it", async () => { const parentHistoryItem = { id: "parent-1", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5887e34ea7..6f9620c254 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -5301,7 +5301,8 @@ export class ClineProvider pendingActionId?: string }): Promise { const { parentTaskId, childTaskId, completionResultSummary, pendingActionId } = params - return this.runDelegationTransition(parentTaskId, async (transitionOwner) => { + let parentToResume: Task | undefined + const didReopen = await this.runDelegationTransition(parentTaskId, async (transitionOwner) => { const globalStoragePath = this.contextProxy.globalStorageUri.fsPath // 1) Load parent from history and current persisted messages @@ -5574,22 +5575,29 @@ export class ClineProvider // non-fatal } - // Auto-resume parent without ask("resume_task") - await parentInstance.resumeAfterDelegation() + parentToResume = parentInstance } - // 9) Emit TaskDelegationResumed (provider-level) + this.cancelledDelegationChildIds.delete(childTaskId) + // The child's publication markers were already invalidated at the + // durable commit boundary above. + return true + }) + + if (didReopen && parentToResume) { + // Resume only after releasing the per-parent transition lock. The + // resumed parent may immediately delegate another child; awaiting it + // while still holding this lock deadlocks that next delegation. + await parentToResume.resumeAfterDelegation() + try { this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) } catch { // non-fatal } + } - this.cancelledDelegationChildIds.delete(childTaskId) - // The child's publication markers were already invalidated at the - // durable commit boundary above. - return true - }) + return didReopen } /** Emits completion after delegated child disposal through the provider-owned event channel. */ From 55ec8d5b75830ffff01c96267c9a98ef94a097a1 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Mon, 7 Sep 2026 15:10:23 +0000 Subject: [PATCH 09/17] chore(build): build @roo-code/types before lifecycle model-check --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fb5497403e..dcada180db 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-provider-handoff.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts", + "lifecycle:model-check": "pnpm --filter @roo-code/types build && tsx scripts/check-task-lifecycle.ts && tsx scripts/check-provider-handoff.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs", "test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only", From dcafae81d785a41cd1d05aae10bad8517dfc98ba Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Mon, 7 Sep 2026 16:06:56 +0000 Subject: [PATCH 10/17] fix(utils): propagate compromised lock after operation settles proper-lockfile requires onCompromised not to throw from inside its callback; the previous rethrow could skip guarded cleanup and leave the lock unreleased. Capture the error, let the operation settle, release the lock, then rethrow. If the operation also failed, its error takes precedence. Add specs for both paths. --- src/utils/__tests__/advisoryFileLock.spec.ts | 46 ++++++++++++++++++++ src/utils/advisoryFileLock.ts | 17 +++++++- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/utils/__tests__/advisoryFileLock.spec.ts diff --git a/src/utils/__tests__/advisoryFileLock.spec.ts b/src/utils/__tests__/advisoryFileLock.spec.ts new file mode 100644 index 0000000000..4fd4fd6fb0 --- /dev/null +++ b/src/utils/__tests__/advisoryFileLock.spec.ts @@ -0,0 +1,46 @@ +const lockMock = vi.hoisted(() => vi.fn()) + +vi.mock("proper-lockfile", () => ({ lock: lockMock })) + +import { withAdvisoryFileLock } from "../advisoryFileLock" + +describe("withAdvisoryFileLock", () => { + beforeEach(() => { + lockMock.mockReset() + }) + + it("propagates a compromised lock after the operation settles and releases the lock", async () => { + const compromised = new Error("lock ownership lost") + const release = vi.fn().mockResolvedValue(undefined) + let onCompromised: ((error: Error) => void) | undefined + lockMock.mockImplementation(async (_filePath: string, options: { onCompromised(error: Error): void }) => { + onCompromised = options.onCompromised + return release + }) + + const operation = withAdvisoryFileLock("/tmp/advisory-lock-test/data.json", async () => { + onCompromised?.(compromised) + return "completed" + }) + + await expect(operation).rejects.toBe(compromised) + expect(release).toHaveBeenCalledOnce() + }) + + it("preserves the operation error when the lock is also compromised", async () => { + const compromised = new Error("lock ownership lost") + const operationError = new Error("operation failed") + const release = vi.fn().mockResolvedValue(undefined) + lockMock.mockImplementation(async (_filePath: string, options: { onCompromised(error: Error): void }) => { + options.onCompromised(compromised) + return release + }) + + await expect( + withAdvisoryFileLock("/tmp/advisory-lock-test/data.json", async () => { + throw operationError + }), + ).rejects.toBe(operationError) + expect(release).toHaveBeenCalledOnce() + }) +}) diff --git a/src/utils/advisoryFileLock.ts b/src/utils/advisoryFileLock.ts index 52f454ea5f..5d11aeeb4d 100644 --- a/src/utils/advisoryFileLock.ts +++ b/src/utils/advisoryFileLock.ts @@ -63,6 +63,7 @@ export async function withAdvisoryFileLock( // always ensured this up front; idempotent for readers). await fs.mkdir(path.dirname(absoluteFilePath), { recursive: true }) + let compromisedError: Error | undefined let releaseLock: () => Promise try { releaseLock = await lockfile.lock(absoluteFilePath, { @@ -72,7 +73,7 @@ export async function withAdvisoryFileLock( retries: options?.retries ?? ADVISORY_LOCK_DEFAULT_RETRIES, onCompromised: (err) => { console.error(`Lock at ${absoluteFilePath} was compromised:`, err) - throw err + compromisedError = err }, }) } catch (lockError) { @@ -81,8 +82,11 @@ export async function withAdvisoryFileLock( throw lockError } + let outcome: { ok: true; value: T } | { ok: false; error: unknown } try { - return await fn() + outcome = { ok: true, value: await fn() } + } catch (error) { + outcome = { ok: false, error } } finally { try { await releaseLock() @@ -92,4 +96,13 @@ export async function withAdvisoryFileLock( console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) } } + + if (!outcome.ok) { + throw outcome.error + } + if (compromisedError) { + throw compromisedError + } + + return outcome.value } From fe3ee261a462e071947033d9be0084faffca1c7a Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Mon, 7 Sep 2026 16:07:31 +0000 Subject: [PATCH 11/17] fix(config): stop reporting an unspecified profile as "undefined" When imported settings name no current profile, the fallback warning previously rendered as Profile "undefined" was not available. Emit "No current profile was specified" instead, keeping the unavailable-profile wording only for a real profile name. --- src/core/config/__tests__/importExport.spec.ts | 2 +- src/core/config/importExport.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 672d10f6b0..937afa2cb8 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -481,7 +481,7 @@ describe("importExport", () => { ) expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "test") expect(result).toMatchObject({ - warnings: [`Profile "undefined" was not available; defaulting to "test".`], + warnings: [`No current profile was specified; defaulting to "test".`], }) }) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 682ae9d374..0c907317c4 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -209,9 +209,12 @@ export async function importSettingsFromPath( currentApiConfigName = undefined } else if (currentApiConfigName === undefined || !validApiConfigs[currentApiConfigName]) { if (validProfileNames.length > 0) { + const previousName = currentApiConfigName currentApiConfigName = validProfileNames[0] warnings.push( - `Profile "${rawProviderProfiles.currentApiConfigName}" was not available; defaulting to "${currentApiConfigName}".`, + previousName === undefined + ? `No current profile was specified; defaulting to "${currentApiConfigName}".` + : `Profile "${previousName}" was not available; defaulting to "${currentApiConfigName}".`, ) } else { // No valid imported profiles; keep the existing currentApiConfigName From ffdff3911c481dee7fd34027054dcc960a34b520 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Mon, 7 Sep 2026 16:07:36 +0000 Subject: [PATCH 12/17] test(config): pin fully migrated state in handoff store write test projectHandoffState assertions now seed an explicit, fully migrated migrations fixture so the locked store write test is deterministic and decoupled from future migration-flag churn. --- .../config/__tests__/ProviderSettingsManager.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 6592b2f79b..082725bebd 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -1603,6 +1603,15 @@ describe("ProviderSettingsManager", () => { }) describe("projectHandoffState", () => { + const fullyMigrated = { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + routerProviderMigrated: true, + } + it("persists the current profile name and mode mapping in one locked store write", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ @@ -1646,6 +1655,7 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "child-profile", apiConfigs: { "child-profile": { id: "child-id", apiProvider: providerIdentifiers.openrouter } }, modeApiConfigs: { ask: "child-id" }, + migrations: fullyMigrated, }), ) mockSecrets.store.mockClear() From e5aaa5b473a4c8354d91f57faebd6d355f16518e Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Mon, 7 Sep 2026 16:07:42 +0000 Subject: [PATCH 13/17] test(webview): wait for projection admission by contract Replace fixed microtask spins with waitForProjectionAdmission, which polls until the bounded queue binds the projection token and admitted generation and fails with a clear error otherwise. --- .../ClineProvider.taskHistory.spec.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 53c26d6557..dcbc1093f2 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -878,6 +878,18 @@ describe("ClineProvider Task History Synchronization", () => { }) } + async function waitForProjectionAdmission(taskId: string) { + for (let attempt = 0; attempt < 25; attempt++) { + const registration = provider["providerHandoffProjectionTargets"]?.get(taskId) + if (registration?.admittedGeneration !== undefined) { + return registration + } + await Promise.resolve() + } + + throw new Error(`Projection for task ${taskId} was not admitted`) + } + it("invalidates handoff projection state immediately after durable completion and before the TaskCompleted event; a late failed settlement cannot resurrect it", async () => { const taskId = "task-cb-handoff-1" await provider.updateTaskHistory(createHistoryItem({ id: taskId, task: "T" }), { broadcast: false }) @@ -895,10 +907,7 @@ describe("ClineProvider Task History Synchronization", () => { // Let the bounded queue admit the projection: it binds its token and // admitted generation, then hangs on the gated write. - for (let i = 0; i < 25; i++) { - await Promise.resolve() - } - const registration = provider["providerHandoffProjectionTargets"]?.get(taskId) + const registration = await waitForProjectionAdmission(taskId) expect(registration).toMatchObject({ token: expect.any(Number), admittedGeneration: expect.any(Number) }) // Observe ordering: the completed write lands before the completion @@ -954,10 +963,7 @@ describe("ClineProvider Task History Synchronization", () => { }) vi.spyOn(provider.providerSettingsManager, "projectHandoffState").mockReturnValue(writeGate) const projection = provider["projectPreparedProviderHandoffState"](makeClearIntentPreparedHandoff(), taskId) - for (let i = 0; i < 25; i++) { - await Promise.resolve() - } - const registration = provider["providerHandoffProjectionTargets"]?.get(taskId) + const registration = await waitForProjectionAdmission(taskId) expect(registration).toMatchObject({ token: expect.any(Number), admittedGeneration: expect.any(Number) }) // Persistence rejects: no durable completed record is established, From bc63fea181818a9c7433f9f4d02aa106735b19b3 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Mon, 7 Sep 2026 22:43:38 +0000 Subject: [PATCH 14/17] fix(delegation): persist a durable child write-ahead handoff record before the delegation commit --- docs/architecture/task-lifecycle-model.md | 33 +- packages/types/src/history.ts | 44 ++ scripts/check-provider-handoff.ts | 142 ++++++- .../ClineProvider.delegation.spec.ts | 183 ++++++++- src/core/task-persistence/TaskHistoryStore.ts | 104 ++++- .../TaskHistoryStore.reconciliation.spec.ts | 150 +++++++ .../__tests__/providerHandoff.spec.ts | 154 ++++++- src/core/task-persistence/index.ts | 3 + src/core/task-persistence/providerHandoff.ts | 94 ++++- src/core/webview/ClineProvider.ts | 73 ++++ .../ClineProvider.apiHandlerRebuild.spec.ts | 8 +- .../ClineProvider.handoffConcurrency.spec.ts | 384 ++++++++++++++++++ 12 files changed, 1322 insertions(+), 50 deletions(-) create mode 100644 src/core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index cc0deee223..ad2dff78c3 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -70,9 +70,18 @@ While the parent is still the current task, handoff preparation is read-only. It If preparation rejects, delegation aborts fail-closed. The parent stays current. -After preparation, the parent is removed. The paused child is created from the prepared all-or-none execution context. The context is validated for completeness at runtime in `ClineProvider` and the `Task` constructor. The delegation is then durably committed through `TaskHistoryStore.atomicReadAndUpdate`. +After preparation, the parent is removed. The paused child is created from the prepared all-or-none execution context. The context is validated for completeness at runtime in `ClineProvider` and the `Task` constructor. -That atomic commit is the single lifecycle commit boundary. Legacy global state, the profile store, and publication are best-effort projections. They run strictly after the commit. They can never undo the commit or block the child from starting. +Before the commit, the child's own history record is written as a durable write-ahead record. It carries a secret-free `pendingHandoff` marker (`HistoryItem.pendingHandoff`, versioned, discriminated `set`/`preserve`/`clear`) holding the requested mode and the explicit profile projection intent. The full API configuration is deliberately NOT persisted: restart re-resolves it from the durable profile store by name, exactly like normal resumed tasks. The protocol makes the delegation commit illegal until this record is durable (`wal-child` before `commit-delegation`; committing from `child-created` is rejected as `wal-required`). + +The delegation is then durably committed through `TaskHistoryStore.atomicReadAndUpdate`. That atomic commit is the single lifecycle commit boundary. After the commit, context activation moves execution authority to the child and the marker is stripped best-effort; a failed strip is replayed by restart reconciliation and never blocks the child from starting. Legacy global state, the profile store, and publication are best-effort projections. They run strictly after the commit. They can never undo the commit or block the child from starting. + +This yields a crash/restart invariant for the handoff identity: the child's durable record always precedes the parent's durable pointer, so a crash between the two writes leaves a recoverable trail instead of a parent pointing at a child whose identity was lost. Startup reconciliation in `TaskHistoryStore.reconcilePendingHandoffRecords` replays it: + +- committed (the parent durably delegates to this child): the stale marker is stripped; the child's persisted `mode`/`apiConfigName` drive the normal resume path, and the existing active-child repair handles the never-started child. +- pre-commit orphan (guarded by valid marker version, lineage to a present parent record, pre-start child status, no delegation bookkeeping of its own, and no matching parent delegation): the child record and its task directory are removed. + +The guards are deliberately conservative: a false negative only leaves a stale record on disk, while a false positive would delete user data. Ambiguous records are left untouched. The invariant covers the mode/profile identity — it does not claim that a frozen secret-bearing API configuration survives restart, and it does not replace the repair journal or `readFresh` reconciliation for parent-record ambiguity. The child is derived entirely from the resolved handoff configuration. This includes profile-derived constructor inputs such as `consecutiveMistakeLimit`. The pre-handoff global configuration can never leak into child execution. @@ -104,7 +113,7 @@ Every other observation is incoherent: a delegation to a different child (`other The labels are diagnostics only. Safety depends solely on continuing for `exact` and rolling back for `unchanged`. Rollback steps run at most once and never before the durability observation. -Rejected orderings include: remove-before-prepare, create-before-remove, commit-before-child, context authority before commit, publication before a durable commit plus activation plus start, rollback during an unresolved commit, and any rollback after a committed delegation. +Rejected orderings include: remove-before-prepare, create-before-remove, commit-before-child, commit-before-child-wal (the delegation commit is illegal until the child's write-ahead record is durable), a second write-ahead write, context authority before commit, publication before a durable commit plus activation plus start, rollback during an unresolved commit, and any rollback after a committed delegation. Queue liveness is bounded with an admission fence. Queue ownership distinguishes admission from execution. A caller whose operation times out after 30 seconds is always released. @@ -210,16 +219,16 @@ These are safety claims within the documented bounds. The checks do not claim li The following table 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. | +| 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. | +| [#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 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/packages/types/src/history.ts b/packages/types/src/history.ts index 5d9671842e..9b4b55b7be 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -26,6 +26,49 @@ export const pendingTaskActionSchema = z.discriminatedUnion("kind", [ export type PendingTaskAction = z.infer +/** + * Durable child-side write-ahead marker for an in-flight provider handoff. + * + * The child's history record is written with this marker BEFORE the parent's + * delegation record is committed, so a crash can never leave a parent + * durably pointing at a child whose handoff identity was lost. The marker + * carries the secret-free execution identity only (requested mode and the + * explicit profile projection intent); the full API configuration is + * deliberately NOT persisted — restart re-resolves it from the durable + * profile store by name, matching normal resumed-task behavior. + * + * - `set`: the handoff carries a named profile identity. + * - `preserve`: workspace profile pinning; the identity (if any) must not + * be rewritten by the handoff projection. + * - `clear`: the handoff carries no profile identity (explicit clear). + * + * Absence of the field means the record is not a pending handoff. The + * marker is stripped once the delegation commit is durable and the child's + * in-memory context is authoritative; restart reconciliation replays the + * strip for committed children and removes orphaned pre-commit children. + */ +export const pendingHandoffSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("set"), + version: z.literal(1), + mode: z.string(), + profileName: z.string(), + }), + z.object({ + kind: z.literal("preserve"), + version: z.literal(1), + mode: z.string(), + profileName: z.string().optional(), + }), + z.object({ + kind: z.literal("clear"), + version: z.literal(1), + mode: z.string(), + }), +]) + +export type PendingHandoff = z.infer + export const historyItemSchema = z.object({ id: z.string(), rootTaskId: z.string().optional(), @@ -49,6 +92,7 @@ export const historyItemSchema = z.object({ completedByChildId: z.string().optional(), // Child that completed and resumed this parent completionResultSummary: z.string().optional(), // Summary from completed child pendingAction: pendingTaskActionSchema.optional(), + pendingHandoff: pendingHandoffSchema.optional(), // Durable child-side write-ahead marker for an in-flight delegation }) export type HistoryItem = z.infer diff --git a/scripts/check-provider-handoff.ts b/scripts/check-provider-handoff.ts index 12ecf642b0..b3b6ac19e8 100644 --- a/scripts/check-provider-handoff.ts +++ b/scripts/check-provider-handoff.ts @@ -29,8 +29,8 @@ type CurrentTaskId = TaskId | undefined const { requestedMode } = createProviderHandoffPlan("child-mode") const currentProfile: ProviderProfileRef = { name: "root-profile", id: "root-profile-id" } const savedProfile: ProviderProfileRef = { name: "child-profile", id: "child-profile-id" } -const MAX_STATES = 600 -const MAX_DEPTH = 16 +const MAX_STATES = 1200 +const MAX_DEPTH = 20 function handoffGeneration(path: ProfilePath): string { return `handoff-generation-${path}` @@ -62,12 +62,26 @@ interface Environment { */ projectionWriteStarted: boolean /** - * The child's durable history record is optional at the commit boundary. - * The model never creates one, so an observed-committed reconciliation is - * always "exact parent delegation without child history", matching - * production's TaskHistoryStore readFresh observation. + * The child's post-start history record is optional at the commit + * boundary; an observed-committed reconciliation is always "exact parent + * delegation without child history", matching production's TaskHistoryStore + * readFresh observation. The child's PRE-start write-ahead record + * (`childWalRecord`) is mandatory before the commit and is modeled + * separately below. */ childHistoryPresent: boolean + /** + * Set once the child's pending-handoff write-ahead record has been made + * durable. Every persisted delegation commit must be preceded by this. + */ + childWalRecordEverWritten: boolean + /** + * The live write-ahead record's secret-free content while it has not yet + * been finalized (or cleaned up after a rollback). Cleared by a successful + * finalize or a successful rollback cleanup; a failed finalize leaves it + * for restart reconciliation, mirroring production. + */ + childWalRecord?: { kind: "set" | "preserve" | "clear"; mode: string } /** Set only by the legacy witness driver: a pre-commit mutating projection. */ preCommitProjectionMutation: boolean /** Set only by the legacy witness driver: a pending-state publication. */ @@ -118,6 +132,7 @@ function initialEnvironment(topology: Topology, profilePath: ProfilePath): Envir commitCount: 0, projectionWriteStarted: false, childHistoryPresent: false, + childWalRecordEverWritten: false, preCommitProjectionMutation: false, pendingPublication: false, } @@ -148,6 +163,16 @@ function expectedModeProfileId(env: Environment): string | undefined { return env.profilePath === "saved" ? savedProfile.id : env.profilePath === "unsaved" ? currentProfile.id : undefined } +/** + * The write-ahead marker's profile-intent kind, resolved like production + * `createPendingHandoffMarker`: a named profile projects as `set`, a + * workspace-locked identity is `preserve`. (The model's fixture profiles are + * always named, so `clear` is exercised by the reducer/production tests.) + */ +function walRecordKind(env: Environment): "set" | "preserve" | "clear" { + return env.profilePath === "locked" ? "preserve" : "set" +} + // --------------------------------------------------------------------------- // Nondeterministic protocol events per phase // --------------------------------------------------------------------------- @@ -174,6 +199,14 @@ function candidateEvents(ms: ModelState): Candidate[] { { name: "create-child-failed", event: { type: "create-child-failed" } }, ] case "child-created": + // Write-ahead durability: the child's pending-handoff record is + // made durable (or the WAL write fails, aborting cleanly) before + // any delegation commit becomes legal. + return [ + { name: "wal-child", event: { type: "wal-child" } }, + { name: "wal-child-failed", event: { type: "wal-child-failed" } }, + ] + case "child-wal-durable": return [ { name: "commit-delegation", event: { type: "commit-delegation" } }, { name: "commit-failed", event: { type: "commit-failed" } }, @@ -181,12 +214,22 @@ function candidateEvents(ms: ModelState): Candidate[] { case "delegation-committed": return [{ name: "activate-context", event: { type: "activate-context", generation } }] case "context-active": { - // The child starts immediately after context activation and must - // never await the legacy projection; the projection itself is - // fire-and-forget background work that may settle before OR after - // the child started. Both orders (and a projection that never - // completes before publication) are protocol states. - const candidates: Candidate[] = [{ name: "start-child", event: { type: "start-child" } }] + // Production finalizes the child's write-ahead marker (best-effort, + // both outcomes modeled) before the child starts, then starts the + // child immediately: the child must never await the legacy + // projection; the projection itself is fire-and-forget background + // work that may settle before OR after the child started. Both + // orders (and a projection that never completes before + // publication) are protocol states. + const candidates: Candidate[] = [] + if (p.childWal === "durable") { + candidates.push( + { name: "finalize-child-wal:ok", event: { type: "finalize-child-wal", ok: true } }, + { name: "finalize-child-wal:fail", event: { type: "finalize-child-wal", ok: false } }, + ) + } else { + candidates.push({ name: "start-child", event: { type: "start-child" } }) + } if (p.projection === "original") { candidates.push( { @@ -291,9 +334,16 @@ function applyEnvironment(ms: ModelState, candidate: Candidate): ModelState { case "prepare-failed": case "create-child-failed": case "activate-context": - case "rollback-cleanup": + case "wal-child-failed": // Read-only steps and protocol-only bookkeeping: no observable change. break + case "rollback-cleanup": + // A successful child cleanup also removes the child's write-ahead + // record (a failed cleanup leaves it for restart reconciliation). + if (candidate.event.ok) { + delete env.childWalRecord + } + break case "remove-parent": env.currentTaskId = env.topology === "exposed-root" ? "root" : undefined break @@ -305,6 +355,19 @@ function applyEnvironment(ms: ModelState, candidate: Candidate): ModelState { generation: candidate.event.generation, } break + case "wal-child": + // The child's pending-handoff record becomes durable; its + // secret-free content mirrors the prepared context exactly. + env.childWalRecordEverWritten = true + env.childWalRecord = { kind: walRecordKind(env), mode: requestedMode } + break + case "finalize-child-wal": + // A successful strip removes the marker; a failed strip leaves it + // on disk for restart reconciliation, mirroring production. + if (candidate.event.ok) { + delete env.childWalRecord + } + break case "commit-delegation": env.parentHistory = delegateTaskToChild(env.parentHistory, "child") env.commitCount += 1 @@ -363,6 +426,17 @@ function violations(ms: ModelState): string[] { if (!sameJson(e.rootTask, initial.rootTask) || !sameJson(e.rootHistory, initial.rootHistory)) { found.push("mutated the unrelated exposed root task") } + // Every persisted delegation commit must be preceded by a durable child + // write-ahead record (the restart-recoverable handoff intent). + if (e.commitCount > 0 && !e.childWalRecordEverWritten) { + found.push("committed a delegation without a durable child write-ahead record") + } + // The write-ahead record's content mirrors the prepared context exactly. + if (e.childWalRecord) { + if (e.childWalRecord.mode !== requestedMode || e.childWalRecord.kind !== walRecordKind(e)) { + found.push("the child write-ahead record diverged from the prepared context") + } + } // No global/profile projection mutation before the delegation is committed. if (p.delegation === "none") { if ( @@ -456,6 +530,9 @@ function violations(ms: ModelState): string[] { ) { found.push("clean abort left child, delegation, publication, or projection residue") } + if (e.childWalRecord) { + found.push("clean abort left the child write-ahead record behind") + } if (!sameJson(e.parentHistory, initial.parentHistory)) { found.push("clean abort did not restore the original parent record") } @@ -522,6 +599,12 @@ function violations(ms: ModelState): string[] { if (p.projection === "original" && e.projectionWriteStarted) { found.push("a started projection write vanished without settling") } + // Production always attempts the best-effort marker strip between + // activation and child start, so a settled state has either finalized + // the marker or left it for restart reconciliation. + if (p.childWal !== "finalized" && p.childWal !== "finalize-failed") { + found.push("settled without finalizing the child write-ahead marker") + } // A started-but-stale projection never overwrites a newer generation's // publication: stale publication derives from the child's prepared // context regardless of the projection write outcome. @@ -563,6 +646,21 @@ function landmarksOf(ms: ModelState): string[] { if (p.rollbackFailures.includes("child-cleanup")) marks.push("rollback:cleanup-failure") if (p.rollbackFailures.includes("parent-restoration")) marks.push("rollback:restoration-failure") } + if (p.phase === "child-wal-durable") { + // The write-ahead record was made durable before any commit. + marks.push("wal:durable-before-commit") + } + if (p.phase === "aborted" && p.failure?.boundary === "child-wal") { + marks.push("abort:child-wal-failure") + } + if (p.phase === "settled" && p.childWal === "finalized") { + marks.push("settlement:wal-finalized") + } + if (p.phase === "settled" && p.childWal === "finalize-failed") { + // The marker survives for restart reconciliation without blocking + // settlement or child start. + marks.push("settlement:wal-restart-replay") + } if (p.failure?.boundary === "delegation-commit" && p.failure.commitDurability === "uncommitted") { marks.push("commit-ambiguity:observed-uncommitted") } @@ -613,6 +711,10 @@ const REQUIRED_LANDMARKS = [ "start:projection-unresolved", "projection:preserve-pinned-identity", "settlement:projection-still-original", + "wal:durable-before-commit", + "abort:child-wal-failure", + "settlement:wal-finalized", + "settlement:wal-restart-replay", ] as const /** Probes attempted on every state to prove illegal orderings are rejected. */ @@ -625,8 +727,11 @@ function probeEvents(profilePath: ProfilePath): Array<{ name: string; event: Pro { name: "create-child", event: { type: "create-child", generation } }, { name: "create-child:mismatch", event: { type: "create-child", generation: FOREIGN_GENERATION } }, { name: "create-child-failed", event: { type: "create-child-failed" } }, + { name: "wal-child", event: { type: "wal-child" } }, + { name: "wal-child-failed", event: { type: "wal-child-failed" } }, { name: "commit-delegation", event: { type: "commit-delegation" } }, { name: "commit-failed", event: { type: "commit-failed" } }, + { name: "finalize-child-wal", event: { type: "finalize-child-wal", ok: true } }, { name: "observe-commit-durability", event: { type: "observe-commit-durability", durability: "uncommitted" }, @@ -645,6 +750,13 @@ const REQUIRED_REJECTIONS = [ "initial:remove-parent", // remove before prepare "prepared:create-child", // create before remove "parent-removed:commit-delegation", // commit before child + "parent-removed:wal-child", // write-ahead record before child creation + "child-created:commit-delegation", // commit before the child write-ahead record is durable + "child-created:commit-failed", // commit before the child write-ahead record is durable + "child-wal-durable:wal-child", // the write-ahead record is written at most once + "delegation-committed:wal-child", // write-ahead record after the commit + "delegation-committed:commit-failed", // exactly one lifecycle commit attempt + "delegation-committed:finalize-child-wal", // marker strip requires context activation "child-created:activate-context", // context authority before commit "child-created:start-child", // start before durable commit + activation "child-created:publish", // publish before durable commit + activation @@ -670,6 +782,10 @@ const REQUIRED_APPLIED_ACTIONS = [ "remove-parent", "create-child", "create-child-failed", + "wal-child", + "wal-child-failed", + "finalize-child-wal:ok", + "finalize-child-wal:fail", "commit-delegation", "commit-failed", "observe-commit-durability:uncommitted", diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 78a699520e..d10e8b30ca 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -25,6 +25,7 @@ const parentHistoryItem: HistoryItem = { function makeStoreStub( overrides: Partial<{ atomicReadAndUpdate: ReturnType + upsert: ReturnType get: ReturnType readFresh: ReturnType invalidate: ReturnType @@ -35,6 +36,9 @@ function makeStoreStub( updater(parentHistoryItem) return [] }), + // The child WAL write and its best-effort post-commit marker strip + // both resolve successfully by default; individual tests override. + upsert: vi.fn(async (item: HistoryItem) => [item]), // A persisted parent record with no delegation, read strictly from its // durable task file: the commit-rejection reconciliation reads this as // an exact nondelegated preimage — definitively uncommitted. The child @@ -90,6 +94,9 @@ const makeChildTask = (taskId: string) => { const run = vi.fn().mockResolvedValue(undefined) return { taskId, + rootTaskId: undefined, + taskNumber: 2, + workspacePath: "/test/workspace", start: vi.fn(), run, adoptHandoffExecutionContext: vi.fn(), @@ -209,6 +216,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { let current: HistoryItem = { ...parentHistoryItem, status: "active", pendingAction } const taskHistoryStore = { get: vi.fn(() => current), + upsert: vi.fn(async (item: HistoryItem) => [item]), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { current = updater(current) return [current] @@ -439,10 +447,13 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }, }) - // Delegation metadata written via atomicReadAndUpdate with correct taskId - expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1) + // Delegation metadata written via atomicReadAndUpdate with correct + // taskId. Two calls total: the parent delegation commit first, then + // the best-effort child pending-handoff marker strip. + expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(2) const [calledTaskId, updater] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] expect(calledTaskId).toBe("parent-1") + expect(taskHistoryStore.atomicReadAndUpdate.mock.calls[1]?.[0]).toBe("child-1") // The updater must produce the correct delegation fields const delegated = updater(parentHistoryItem) @@ -468,6 +479,145 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") }) + it("persists the child write-ahead record before the delegation commit", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + const taskHistoryStore = makeStoreStub() + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + // The child WAL write happened strictly before the parent commit. + const store = taskHistoryStore as unknown as { + upsert: ReturnType + atomicReadAndUpdate: ReturnType + } + expect(store.upsert).toHaveBeenCalledTimes(1) + expect(store.upsert.mock.invocationCallOrder[0]).toBeLessThan( + store.atomicReadAndUpdate.mock.invocationCallOrder[0], + ) + const walRecord = store.upsert.mock.calls[0]?.[0] as HistoryItem + // Secret-free write-ahead intent: mode + explicit profile intent only. + expect(walRecord).toMatchObject({ + id: "child-1", + parentTaskId: "parent-1", + status: "active", + mode: "code", + apiConfigName: "profile-1", + pendingHandoff: { kind: "set", version: 1, mode: "code", profileName: "profile-1" }, + }) + expect(JSON.stringify(walRecord)).not.toContain("apiKey") + }) + + it("fails closed when the child write-ahead write rejects: no commit, parent restored", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const walError = new Error("disk full") + const taskHistoryStore = makeStoreStub({ + upsert: vi.fn().mockRejectedValue(walError), + }) + const deleteTaskWithId = vi.fn().mockResolvedValue(undefined) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: { ...parentHistoryItem, status: "active" } }) + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + deleteTaskWithId, + createTaskWithHistoryItem, + getTaskWithId, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + // Fail closed: the original WAL error surfaces and the delegation commit + // never runs. + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow("disk full") + + const store = taskHistoryStore as unknown as { atomicReadAndUpdate: ReturnType } + expect(store.atomicReadAndUpdate).not.toHaveBeenCalled() + expect(child.adoptHandoffExecutionContext).not.toHaveBeenCalled() + expect(child.run).not.toHaveBeenCalled() + // Rollback: child deleted, parent restored. + expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) + expect(createTaskWithHistoryItem).toHaveBeenCalled() + }) + + it("starts the child when the post-commit marker strip fails (restart replay covers it)", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const providerEmit = vi.fn() + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async (taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + if (taskId === "child-1") { + // The best-effort marker strip failed. + throw new Error("strip rejected") + } + updater(parentHistoryItem) + return [] + }), + }) + const log = vi.fn() + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + log, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + const result = await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + // The marker failure is non-fatal: the durable delegation and the + // authoritative child context carry the handoff. + expect(result).toBe(child) + expect(child.run).toHaveBeenCalledTimes(1) + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") + expect(log).toHaveBeenCalledWith(expect.stringContaining("restart replay")) + }) + it("posts taskHistoryItemUpdated to the webview when isViewLaunched is true", async () => { const updatedParent = { ...parentHistoryItem, status: "delegated" } as HistoryItem const postMessageToWebview = vi.fn().mockResolvedValue(undefined) @@ -581,8 +731,15 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // prepare → createTask → atomicReadAndUpdate → child.run: read-only // preparation completes before the parent leaves the stack, and the - // scheduler admits the child only after metadata is persisted - expect(callOrder).toEqual(["prepareProviderHandoffContext", "createTask", "atomicReadAndUpdate", "child.run"]) + // scheduler admits the child only after metadata is persisted. The + // child WAL write and its marker strip both count as atomic store ops. + expect(callOrder).toEqual([ + "prepareProviderHandoffContext", + "createTask", + "atomicReadAndUpdate", + "atomicReadAndUpdate", + "child.run", + ]) }) it("implicitly severs interrupted awaited child and re-delegates when parent is already delegated", async () => { @@ -1139,8 +1296,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { .providerHandoffProjectionCompletion expect(result).toBe(child) - // Delegation was committed and announced; the child started. - expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1) + // Delegation was committed and announced; the child started. Both + // store updates ran: the parent commit and the child marker strip. + expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(2) expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") expect(child.run).toHaveBeenCalledTimes(1) // The failure was logged redacted — never with the sentinel secret value. @@ -1920,7 +2078,12 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const records = new Map([["parent-1", { ...parentHistoryItem }]]) const taskHistoryStore = makeStoreStub({ - atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + atomicReadAndUpdate: vi.fn(async (taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + if (taskId === "child-1") { + // The best-effort child pending-handoff marker strip. + order.push("finalize-child-wal") + return [] + } await commitGate records.set("parent-1", updater(structuredClone(records.get("parent-1")!))) order.push("committed") @@ -1981,9 +2144,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { releaseCommit() await delegation expect(await completion).toBe(false) - // The completion's store read happened only after the delegation committed. + // The completion's store read happened only after the delegation + // committed and its marker strip finalized inside the delegation. expect(order[0]).toBe("committed") - expect(order[1]).toBe("read:parent-1") + expect(order[1]).toBe("finalize-child-wal") + expect(order[2]).toBe("read:parent-1") }) it("starts the child after a timed-out projection and ignores the late completion", async () => { diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 25e29c1c16..66e528739f 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -13,8 +13,9 @@ import { withAdvisoryFileLock, ADVISORY_READ_LOCK_RETRIES, } from "../../utils/safeWriteJson" -import { getStorageBasePath } from "../../utils/storage" +import { getStorageBasePath, getTaskDirectoryPath } from "../../utils/storage" import { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" +import { isValidPendingHandoff } from "./providerHandoff" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" export { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" @@ -442,6 +443,11 @@ export class TaskHistoryStore { let repairsInThisPass: number do { repairsInThisPass = 0 + // Replay the child-side delegation write-ahead records BEFORE the + // delegated-parent pass, using its own snapshot: the pass below must + // see the finalized or removed post-sweep cache, not a stale copy. + await this.reconcilePendingHandoffRecords(new Map(Array.from(this.cache.values()).map((i) => [i.id, i]))) + // Rebuild the lookup map each pass so repairs from the previous pass // are visible when evaluating chained delegations. const byId = new Map(Array.from(this.cache.values()).map((i) => [i.id, i])) @@ -516,6 +522,102 @@ export class TaskHistoryStore { } while (repairsInThisPass > 0) } + /** + * Replay the child-side delegation write-ahead records (`pendingHandoff`). + * + * The delegating provider writes the child's history record with this + * marker BEFORE the parent's delegation record is committed, so a crash + * between the two writes leaves a recoverable trail: + * + * - committed: the parent record durably points at this child + * (`status === "delegated"` and `awaitingChildId === child.id`). The + * marker is stale bookkeeping; strip it (idempotent finalization + * replay). The normal delegated-parent pass below then handles the + * child's status as usual. + * - orphan: the parent never durably committed this delegation. If — and + * only if — every guard predicate holds (valid marker version, lineage + * to a present parent record, pre-start child status, no delegation + * bookkeeping of its own, and no matching parent delegation), the + * pre-start child record and its task directory are removed. + * + * Guards are deliberately conservative: a false negative only leaves a + * stale record on disk, while a false positive would delete user data. + * Ambiguous records are left untouched. Must run under the store lock. + */ + private async reconcilePendingHandoffRecords(byId: ReadonlyMap): Promise { + for (const [, item] of byId) { + const pending = item.pendingHandoff + if (!pending) continue + + // Guard 1: only markers this build understands are actionable. + if (!isValidPendingHandoff(pending)) continue + + // Guard 2: lineage — a write-ahead record always references its + // delegating parent, and that record must be present. + const parentId = item.parentTaskId + if (!parentId || !this.isSafeTaskId(parentId)) continue + const parent = byId.get(parentId) + if (!parent) continue + + if (parent.status === "delegated" && parent.awaitingChildId === item.id) { + // Committed: the parent delegation is durable. Strip the stale + // marker; the child's own mode/apiConfigName fields remain. + try { + await this.upsertCore({ ...item, pendingHandoff: undefined }, { skipTransitionCheck: true }) + console.warn(`[TaskHistoryStore] Finalized pending handoff marker for committed child ${item.id}`) + } catch (error) { + console.error( + `[TaskHistoryStore] Failed to finalize pending handoff marker for child ${item.id}:`, + error, + ) + } + continue + } + + // Guard 3: pre-start only. A child that ran (messages, completion, + // its own delegation, or a terminal status) is never deleted here. + if ((item.status ?? "active") !== "active") continue + if ( + item.awaitingChildId !== undefined || + item.delegatedToId !== undefined || + item.completedByChildId !== undefined || + item.completionResultSummary !== undefined + ) { + continue + } + + // Guard 4: the parent record exists and does NOT delegate to this + // child (checked above) — a pre-commit orphan. + if (!this.isSafeTaskId(item.id)) continue + try { + this.cache.delete(item.id) + this.taskFileMtimes.delete(item.id) + try { + await fs.unlink(await this.getTaskFilePath(item.id)) + } catch { + // Record file may already be gone. + } + try { + const taskDir = await getTaskDirectoryPath(this.globalStoragePath, item.id) + await fs.rm(taskDir, { recursive: true, force: true }) + } catch (error) { + console.warn( + `[TaskHistoryStore] Failed to remove orphaned handoff task directory ${item.id}:`, + error, + ) + } + if (this.onWrite) { + await this.onWrite(this.getAll()) + } + console.warn( + `[TaskHistoryStore] Removed pre-commit orphaned handoff child ${item.id} (parent ${parentId} never delegated to it)`, + ) + } catch (error) { + console.error(`[TaskHistoryStore] Failed to remove orphaned handoff child ${item.id}:`, error) + } + } + } + private getPersistedActiveIds(): ReadonlySet { return new Set( Array.from(this.cache.values()) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts index e37fd1a25e..32ee4c0654 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts @@ -1016,3 +1016,153 @@ describe("TaskHistoryStore upsert transition guard", () => { ).rejects.toThrow("Invalid task status transition: delegated → completed") }) }) + +// ───────────────────────────────────────────────────────────────────────────── +// pendingHandoff write-ahead records — restart replay +// ───────────────────────────────────────────────────────────────────────────── + +describe("TaskHistoryStore pendingHandoff reconciliation", () => { + let tmpDir: string + let store: TaskHistoryStore + const disposables = new Set() + + function registerStore(nextStore: TaskHistoryStore): TaskHistoryStore { + disposables.add(nextStore) + return nextStore + } + + async function seedItems(items: HistoryItem[]): Promise { + const tasksDir = path.join(tmpDir, "tasks") + for (const item of items) { + const taskDir = path.join(tasksDir, item.id) + await fs.mkdir(taskDir, { recursive: true }) + await fs.writeFile(path.join(taskDir, "history_item.json"), JSON.stringify(item)) + } + } + + async function taskFileExists(taskId: string): Promise { + try { + await fs.access(path.join(tmpDir, "tasks", taskId, "history_item.json")) + return true + } catch { + return false + } + } + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pending-handoff-reconcile-")) + store = registerStore(new TaskHistoryStore(tmpDir)) + }) + + afterEach(async () => { + for (const disposable of disposables) disposable.dispose() + disposables.clear() + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + }) + + it("removes a guarded pre-commit orphan: child WAL record whose parent never delegated", async () => { + const child = makeItem({ + id: "wal-orphan", + parentTaskId: "wal-parent", + status: "active", + mode: "ask", + apiConfigName: "profile-1", + pendingHandoff: { kind: "set", version: 1, mode: "ask", profileName: "profile-1" }, + }) + const parent = makeItem({ id: "wal-parent", status: "active" }) + await seedItems([parent, child]) + + await store.initialize() + + // The orphan and its task directory are gone; the parent is untouched. + expect(store.get("wal-orphan")).toBeUndefined() + expect(await taskFileExists("wal-orphan")).toBe(false) + expect(store.get("wal-parent")?.status).toBe("active") + }) + + it("strips the marker from a committed child and recovers the delegation for resume", async () => { + const child = makeItem({ + id: "wal-child", + parentTaskId: "wal-parent", + status: "active", + mode: "ask", + apiConfigName: "profile-1", + pendingHandoff: { kind: "set", version: 1, mode: "ask", profileName: "profile-1" }, + }) + const parent = makeItem({ id: "wal-parent", status: "delegated", awaitingChildId: "wal-child" }) + await seedItems([parent, child]) + + await store.initialize() + + // The persisted handoff identity survives restart: the child record + // keeps mode/apiConfigName while the marker is replayed away, and the + // existing active-child repair marks it interrupted for re-delegation + // or resume. + const recoveredChild = store.get("wal-child") + expect(recoveredChild?.pendingHandoff).toBeUndefined() + expect(recoveredChild?.mode).toBe("ask") + expect(recoveredChild?.apiConfigName).toBe("profile-1") + expect(recoveredChild?.status).toBe("interrupted") + expect(store.get("wal-parent")?.status).toBe("active") + }) + + it("leaves ambiguous WAL records untouched (fail-safe guards)", async () => { + const unknownVersion = makeItem({ + id: "wal-guard-version", + parentTaskId: "wal-guard-parent", + status: "active", + // A future/unknown version must fail safe; the double cast models a + // marker written by a newer build found on disk. + pendingHandoff: { kind: "clear", version: 99 as unknown as 1, mode: "ask" }, + }) + const interruptedChild = makeItem({ + id: "wal-guard-interrupted", + parentTaskId: "wal-guard-parent", + status: "interrupted", + pendingHandoff: { kind: "clear", version: 1, mode: "ask" }, + }) + const noParent = makeItem({ + id: "wal-guard-orphan-root", + status: "active", + pendingHandoff: { kind: "clear", version: 1, mode: "ask" }, + }) + const missingParentChild = makeItem({ + id: "wal-guard-missing-parent", + parentTaskId: "wal-guard-gone", + status: "active", + pendingHandoff: { kind: "clear", version: 1, mode: "ask" }, + }) + const supersededChild = makeItem({ + id: "wal-guard-superseded", + parentTaskId: "wal-guard-parent", + status: "active", + completionResultSummary: "partial work", + pendingHandoff: { kind: "clear", version: 1, mode: "ask" }, + }) + // NOTE: "wal-guard-gone" is deliberately NOT seeded so the + // "wal-guard-missing-parent" child has a missing parent record, which + // the sweep must treat conservatively. + await seedItems([ + makeItem({ id: "wal-guard-parent", status: "active" }), + unknownVersion, + interruptedChild, + noParent, + missingParentChild, + supersededChild, + ]) + + await store.initialize() + + // Every ambiguous record survives; nothing was deleted. + for (const id of [ + "wal-guard-version", + "wal-guard-interrupted", + "wal-guard-orphan-root", + "wal-guard-missing-parent", + "wal-guard-superseded", + ]) { + expect(store.get(id), id).toBeDefined() + expect(await taskFileExists(id), id).toBe(true) + } + }) +}) diff --git a/src/core/task-persistence/__tests__/providerHandoff.spec.ts b/src/core/task-persistence/__tests__/providerHandoff.spec.ts index ccdf0ab830..a056bd1848 100644 --- a/src/core/task-persistence/__tests__/providerHandoff.spec.ts +++ b/src/core/task-persistence/__tests__/providerHandoff.spec.ts @@ -5,9 +5,11 @@ import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { applyProviderHandoffEvent, createPreparedProviderHandoffContext, + createPendingHandoffMarker, createProviderHandoffPlan, createProviderHandoffTransaction, classifyProviderHandoffProjectionResults, + isValidPendingHandoff, decideProviderHandoffProfile, getProviderHandoffActivationOptions, initialProviderHandoffState, @@ -66,6 +68,61 @@ describe("provider handoff contract", () => { expect(prepared.profile.intent).toEqual({ kind: "preserve" }) }) + it("builds a secret-free write-ahead marker mirroring the prepared intent", () => { + const base = { apiConfiguration: { apiProvider: providerIdentifiers.openai } } + expect( + createPendingHandoffMarker({ + prepared: createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "saved", name: "saved-profile", id: "saved-id" }, + ...base, + }), + }), + ).toEqual({ kind: "set", version: 1, mode: "code", profileName: "saved-profile" }) + expect( + createPendingHandoffMarker({ + prepared: createPreparedProviderHandoffContext({ + requestedMode: "architect", + profile: { source: "locked-current", name: "pinned", id: "pinned-id" }, + ...base, + }), + }), + ).toEqual({ kind: "preserve", version: 1, mode: "architect", profileName: "pinned" }) + expect( + createPendingHandoffMarker({ + prepared: createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "unsaved-current", name: undefined, id: undefined }, + ...base, + }), + }), + ).toEqual({ kind: "clear", version: 1, mode: "code" }) + // The marker never carries configuration or secret-shaped fields. + const marker = createPendingHandoffMarker({ + prepared: createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "saved", name: "p", id: "p-id" }, + apiConfiguration: { apiProvider: providerIdentifiers.openai, openAiApiKey: "sk-secret" }, + }), + }) + expect(JSON.stringify(marker)).not.toContain("sk-secret") + expect(JSON.stringify(marker)).not.toContain("apiKey") + }) + + it("validates parsed write-ahead markers and rejects unknown shapes and versions", () => { + expect(isValidPendingHandoff({ kind: "set", version: 1, mode: "code", profileName: "p" })).toBe(true) + expect(isValidPendingHandoff({ kind: "preserve", version: 1, mode: "code" })).toBe(true) + expect(isValidPendingHandoff({ kind: "preserve", version: 1, mode: "code", profileName: "p" })).toBe(true) + expect(isValidPendingHandoff({ kind: "clear", version: 1, mode: "code" })).toBe(true) + // Unknown version: fail safe, leave untouched. + expect(isValidPendingHandoff({ kind: "clear", version: 2, mode: "code" })).toBe(false) + expect(isValidPendingHandoff({ kind: "set", version: 1, mode: "code" })).toBe(false) + expect(isValidPendingHandoff({ kind: "unknown", version: 1, mode: "code" })).toBe(false) + expect(isValidPendingHandoff({ kind: "clear", version: 1 })).toBe(false) + expect(isValidPendingHandoff(null)).toBe(false) + expect(isValidPendingHandoff("clear")).toBe(false) + }) + it("selects the current profile while workspace profile locking is enabled", () => { expect( decideProviderHandoffProfile({ @@ -271,6 +328,7 @@ describe("provider handoff transaction protocol", () => { { type: "prepare", generation: GENERATION }, { type: "remove-parent" }, { type: "create-child", generation: GENERATION }, + { type: "wal-child" }, { type: "commit-delegation" }, { type: "activate-context", generation: GENERATION }, { type: "project-legacy", boundary: "profile-store", ok: true }, @@ -278,6 +336,32 @@ describe("provider handoff transaction protocol", () => { { type: "publish" }, ] + it("fails closed when the child write-ahead write fails, restoring cleanly", () => { + const initial = initialProviderHandoffState() + const prepared = applyProviderHandoffEvent(initial, { type: "prepare", generation: GENERATION }) + if (!prepared.ok) throw new Error("unreachable") + const removed = applyProviderHandoffEvent(prepared.state, { type: "remove-parent" }) + if (!removed.ok) throw new Error("unreachable") + const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) + if (!created.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(created.state, { type: "wal-child-failed" }) + if (!failed.ok) throw new Error("unreachable") + expect(failed.state).toMatchObject({ + phase: "aborting", + failure: { boundary: "child-wal" }, + childWal: "none", + }) + const cleaned = applyProviderHandoffEvent(failed.state, { type: "rollback-cleanup", ok: true }) + if (!cleaned.ok) throw new Error("unreachable") + const restored = applyProviderHandoffEvent(cleaned.state, { type: "rollback-restore", ok: true }) + expect(restored.ok && restored.state.phase).toBe("aborted") + // No commit may be attempted from the pre-WAL state. + expect(applyProviderHandoffEvent(created.state, { type: "commit-delegation" })).toMatchObject({ + ok: false, + reason: "wal-required", + }) + }) + it("walks the legal happy path from initial to settled with one prepared generation", () => { const { states, rejections } = drive(initialProviderHandoffState(), happyPath) @@ -288,6 +372,7 @@ describe("provider handoff transaction protocol", () => { "prepared", "parent-removed", "child-created", + "child-wal-durable", "delegation-committed", "context-active", "context-active", @@ -338,6 +423,15 @@ describe("provider handoff transaction protocol", () => { expect(created.ok).toBe(true) if (!created.ok) throw new Error("unreachable") + // commit before the child write-ahead record is durable + expect(applyProviderHandoffEvent(created.state, { type: "commit-delegation" })).toMatchObject({ + ok: false, + reason: "wal-required", + }) + const wal = applyProviderHandoffEvent(created.state, { type: "wal-child" }) + expect(wal.ok).toBe(true) + if (!wal.ok) throw new Error("unreachable") + // context authority before commit expect( applyProviderHandoffEvent(created.state, { type: "activate-context", generation: GENERATION }), @@ -345,7 +439,7 @@ describe("provider handoff transaction protocol", () => { ok: false, reason: "commit-required", }) - const committed = applyProviderHandoffEvent(created.state, { type: "commit-delegation" }) + const committed = applyProviderHandoffEvent(wal.state, { type: "commit-delegation" }) expect(committed.ok).toBe(true) if (!committed.ok) throw new Error("unreachable") @@ -395,7 +489,9 @@ describe("provider handoff transaction protocol", () => { }) const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) if (!created.ok) throw new Error("unreachable") - const committed = applyProviderHandoffEvent(created.state, { type: "commit-delegation" }) + const wal = applyProviderHandoffEvent(created.state, { type: "wal-child" }) + if (!wal.ok) throw new Error("unreachable") + const committed = applyProviderHandoffEvent(wal.state, { type: "commit-delegation" }) if (!committed.ok) throw new Error("unreachable") expect( applyProviderHandoffEvent(committed.state, { type: "activate-context", generation: "other-generation" }), @@ -472,7 +568,9 @@ describe("provider handoff transaction protocol", () => { if (!removed.ok) throw new Error("unreachable") const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) if (!created.ok) throw new Error("unreachable") - const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + const wal = applyProviderHandoffEvent(created.state, { type: "wal-child" }) + if (!wal.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(wal.state, { type: "commit-failed" }) if (!failed.ok) throw new Error("unreachable") // Production reconciles the durability before any destructive step. @@ -494,7 +592,9 @@ describe("provider handoff transaction protocol", () => { if (!removed.ok) throw new Error("unreachable") const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) if (!created.ok) throw new Error("unreachable") - const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + const wal = applyProviderHandoffEvent(created.state, { type: "wal-child" }) + if (!wal.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(wal.state, { type: "commit-failed" }) if (!failed.ok) throw new Error("unreachable") expect(failed.state).toMatchObject({ phase: "aborting", @@ -573,7 +673,9 @@ describe("provider handoff transaction protocol", () => { if (!removed.ok) throw new Error("unreachable") const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) if (!created.ok) throw new Error("unreachable") - const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + const wal = applyProviderHandoffEvent(created.state, { type: "wal-child" }) + if (!wal.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(wal.state, { type: "commit-failed" }) if (!failed.ok) throw new Error("unreachable") const resolved = applyProviderHandoffEvent(failed.state, { type: "observe-commit-durability", @@ -593,7 +695,7 @@ describe("provider handoff transaction protocol", () => { it("permits a projection failure after the commit without invalidating child authority", () => { const initial = initialProviderHandoffState() - const activated = drive(initial, happyPath.slice(0, 5)).states[5]! + const activated = drive(initial, happyPath.slice(0, 6)).states[6]! expect(activated.phase).toBe("context-active") expect(activated.contextAuthority).toBe("child") @@ -626,7 +728,7 @@ describe("provider handoff transaction protocol", () => { it("starts the child without awaiting the legacy projection, which may settle afterwards", () => { const initial = initialProviderHandoffState() - const activated = drive(initial, happyPath.slice(0, 5)).states[5]! + const activated = drive(initial, happyPath.slice(0, 6)).states[6]! // Start with the projection still original: the child never waits for // background legacy projection work. @@ -673,7 +775,9 @@ describe("provider handoff transaction protocol", () => { if (!removed.ok) throw new Error("unreachable") const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) if (!created.ok) throw new Error("unreachable") - const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + const wal = applyProviderHandoffEvent(created.state, { type: "wal-child" }) + if (!wal.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(wal.state, { type: "commit-failed" }) if (!failed.ok) throw new Error("unreachable") const observed = applyProviderHandoffEvent(failed.state, { @@ -698,7 +802,9 @@ describe("provider handoff transaction protocol", () => { if (!removed.ok) throw new Error("unreachable") const created = applyProviderHandoffEvent(removed.state, { type: "create-child", generation: GENERATION }) if (!created.ok) throw new Error("unreachable") - const failed = applyProviderHandoffEvent(created.state, { type: "commit-failed" }) + const wal = applyProviderHandoffEvent(created.state, { type: "wal-child" }) + if (!wal.ok) throw new Error("unreachable") + const failed = applyProviderHandoffEvent(wal.state, { type: "commit-failed" }) if (!failed.ok) throw new Error("unreachable") return failed.state } @@ -735,9 +841,9 @@ describe("provider handoff transaction protocol", () => { it("carries no secrets or configuration in protocol state", () => { const { states } = drive(initialProviderHandoffState(), [ - ...happyPath.slice(0, 5), + ...happyPath.slice(0, 6), { type: "project-legacy", boundary: "profile-store", ok: false }, - ...happyPath.slice(6), + ...happyPath.slice(7), ]) const expectedKeys = [ "phase", @@ -746,6 +852,7 @@ describe("provider handoff transaction protocol", () => { "delegation", "generation", "contextAuthority", + "childWal", "projection", "projectionFailure", "publication", @@ -765,6 +872,7 @@ describe("provider handoff transaction protocol", () => { "prepared", "parent-removed", "child-created", + "child-wal-durable", "delegation-committed", "context-active", "child-running", @@ -789,9 +897,14 @@ describe("provider handoff transaction protocol", () => { // failure and projection boundaries, durability observations "preparation", "child-creation", + "child-wal", "delegation-commit", "child-cleanup", "parent-restoration", + // write-ahead marker durability + "durable", + "finalized", + "finalize-failed", "profile-store", "context-proxy", "queue", @@ -837,6 +950,25 @@ describe("provider handoff transaction wrapper", () => { phase: "child-created", generation: transaction.generation, }) + // The commit is illegal until the child write-ahead record is durable. + expect(transaction.advance({ type: "commit-delegation" })).toMatchObject({ + ok: false, + reason: "wal-required", + }) + expect(transaction.advance({ type: "wal-child" }).ok).toBe(true) + expect(transaction.snapshot()).toMatchObject({ phase: "child-wal-durable", childWal: "durable" }) + expect(transaction.advance({ type: "wal-child" })).toMatchObject({ ok: false, reason: "unexpected-event" }) + expect(transaction.advance({ type: "commit-delegation" }).ok).toBe(true) + expect(transaction.advance({ type: "activate-context" }).ok).toBe(true) + // The marker strip is best-effort: both outcomes keep the child start legal. + expect(transaction.advance({ type: "finalize-child-wal", ok: false }).ok).toBe(true) + expect(transaction.snapshot()).toMatchObject({ phase: "context-active", childWal: "finalize-failed" }) + expect(transaction.advance({ type: "finalize-child-wal", ok: true })).toMatchObject({ + ok: false, + reason: "unexpected-event", + }) + expect(transaction.advance({ type: "start-child" }).ok).toBe(true) + expect(transaction.snapshot()).toMatchObject({ phase: "child-running", childWal: "finalize-failed" }) }) it("generates distinct opaque generations per transaction", () => { diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index c80761c466..5c229f864d 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -19,8 +19,11 @@ export { classifyProviderHandoffProjectionResults, createPreparedProviderHandoffContext, createProviderHandoffPlan, + createPendingHandoffMarker, createProviderHandoffTransaction, decideProviderHandoffProfile, + isValidPendingHandoff, + PENDING_HANDOFF_VERSION, getProviderHandoffActivationOptions, initialProviderHandoffState, PRODUCTION_PROVIDER_HANDOFF_POLICY, diff --git a/src/core/task-persistence/providerHandoff.ts b/src/core/task-persistence/providerHandoff.ts index 1b2aa1fe9d..94cf77a148 100644 --- a/src/core/task-persistence/providerHandoff.ts +++ b/src/core/task-persistence/providerHandoff.ts @@ -1,4 +1,4 @@ -import { isSecretStateKey, type ProviderSettings } from "@roo-code/types" +import { isSecretStateKey, type PendingHandoff, type ProviderSettings } from "@roo-code/types" export interface ProviderProfileRef { name: string @@ -148,6 +148,55 @@ export function createPreparedProviderHandoffContext(params: { return context } +/** Current durable write-ahead marker schema version. */ +export const PENDING_HANDOFF_VERSION = 1 + +/** + * Derive the secret-free durable child write-ahead marker from a prepared + * handoff context. This is what the child's history record carries BEFORE the + * parent delegation is durably committed, so a crash between the two writes + * can always classify the child as a recoverable pre-commit orphan. It carries + * the execution identity (mode + explicit profile intent) only — never the API + * configuration, and never any provider secret. + */ +export function createPendingHandoffMarker(params: { prepared: PreparedProviderHandoffContext }): PendingHandoff { + const { prepared } = params + const base = { version: PENDING_HANDOFF_VERSION, mode: prepared.requestedMode } as const + switch (prepared.profile.intent.kind) { + case "set": + return { kind: "set", ...base, profileName: prepared.profile.intent.name } + case "preserve": + return prepared.profile.name + ? { kind: "preserve", ...base, profileName: prepared.profile.name } + : { kind: "preserve", ...base } + case "clear": + return { kind: "clear", ...base } + } +} + +/** + * Runtime validation for a parsed `pendingHandoff` marker. Used by restart + * reconciliation so an unknown-version or malformed marker is left untouched + * (fail safe: conservative false negatives, never destructive false + * positives). + */ +export function isValidPendingHandoff(value: unknown): value is PendingHandoff { + if (!value || typeof value !== "object") return false + const candidate = value as Record + if (candidate.version !== PENDING_HANDOFF_VERSION) return false + if (typeof candidate.mode !== "string") return false + switch (candidate.kind) { + case "set": + return typeof candidate.profileName === "string" && candidate.profileName.length > 0 + case "preserve": + return candidate.profileName === undefined || typeof candidate.profileName === "string" + case "clear": + return true + default: + return false + } +} + /** * Best-effort secret redaction for error messages logged around handoff * state. Removes values of provider secret fields so projection failures can @@ -202,6 +251,7 @@ export type ProviderHandoffPhase = | "prepared" | "parent-removed" | "child-created" + | "child-wal-durable" | "delegation-committed" | "context-active" | "child-running" @@ -214,6 +264,7 @@ export type ProviderHandoffPhase = export type ProviderHandoffFailureBoundary = | "preparation" | "child-creation" + | "child-wal" | "delegation-commit" | "child-cleanup" | "parent-restoration" @@ -341,6 +392,14 @@ export interface ProviderHandoffState { readonly generation: string | undefined /** Owner of the child execution context: the parent until the commit, the child after. */ readonly contextAuthority: ProviderHandoffContextAuthority + /** + * Durability of the child-side write-ahead handoff record. The + * delegation commit is legal only once the child's pending-handoff + * record is durable ("durable"); "finalized"/"finalize-failed" record + * the best-effort post-commit marker strip (restart replay covers a + * failure). + */ + readonly childWal: "none" | "durable" | "finalized" | "finalize-failed" readonly projection: ProviderHandoffProjectionState readonly projectionFailure: ProviderHandoffProjectionBoundary | undefined readonly publication: ProviderHandoffPublicationState @@ -358,8 +417,17 @@ export type ProviderHandoffEvent = | { type: "remove-parent" } | { type: "create-child"; generation: string } | { type: "create-child-failed" } + /** The child's durable write-ahead handoff record persisted (WAL before commit). */ + | { type: "wal-child" } + /** The child WAL write failed: fail closed, clean up the child, restore the parent. */ + | { type: "wal-child-failed" } | { type: "commit-delegation" } | { type: "commit-failed" } + /** + * Best-effort post-commit strip of the child's pending-handoff marker. A + * failure is non-fatal: restart reconciliation replays the strip. + */ + | { type: "finalize-child-wal"; ok: boolean } | { type: "observe-commit-durability" durability: "uncommitted" | "committed" | "incoherent" @@ -386,6 +454,7 @@ export type ProviderHandoffRejection = | "preparation-required" | "parent-not-removed" | "child-required" + | "wal-required" | "commit-required" | "context-activation-required" | "child-not-running" @@ -412,6 +481,7 @@ export function initialProviderHandoffState(): ProviderHandoffState { delegation: "none", generation: undefined, contextAuthority: "parent", + childWal: "none", projection: "original", projectionFailure: undefined, publication: "none", @@ -468,13 +538,23 @@ export function applyProviderHandoffEvent( case "create-child-failed": if (state.phase !== "parent-removed") return reject(state, "unexpected-event") return accept({ ...state, phase: "aborting", failure: { boundary: "child-creation" } }) + case "wal-child": + // Write-ahead durability: the child's pending-handoff record must be + // on disk before the delegation commit becomes legal. + if (state.phase !== "child-created") return reject(state, "unexpected-event") + return accept({ ...state, phase: "child-wal-durable", childWal: "durable" }) + case "wal-child-failed": + if (state.phase !== "child-created") return reject(state, "unexpected-event") + return accept({ ...state, phase: "aborting", failure: { boundary: "child-wal" } }) case "commit-delegation": if (state.commitAttempts > 0) return reject(state, "commit-already-attempted") - if (state.phase !== "child-created") return reject(state, "child-required") + if (state.phase === "child-created") return reject(state, "wal-required") + if (state.phase !== "child-wal-durable") return reject(state, "child-required") return accept({ ...state, phase: "delegation-committed", delegation: "committed", commitAttempts: 1 }) case "commit-failed": if (state.commitAttempts > 0) return reject(state, "commit-already-attempted") - if (state.phase !== "child-created") return reject(state, "child-required") + if (state.phase === "child-created") return reject(state, "wal-required") + if (state.phase !== "child-wal-durable") return reject(state, "child-required") return accept({ ...state, phase: "aborting", @@ -530,6 +610,14 @@ export function applyProviderHandoffEvent( } if (event.generation !== state.generation) return reject(state, "generation-mismatch") return accept({ ...state, phase: "context-active", contextAuthority: "child" }) + case "finalize-child-wal": + // Best-effort marker strip between activation and child start. A + // failure stays visible ("finalize-failed") and is replayed by the + // restart reconciliation; it never blocks the child from starting. + if (state.phase !== "context-active" || state.childWal !== "durable") { + return reject(state, "unexpected-event") + } + return accept({ ...state, childWal: event.ok ? "finalized" : "finalize-failed" }) case "project-legacy": // Legacy projection is background work: it may settle while the // protocol is still in context-active OR after the child already diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6f9620c254..667dde1d50 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -122,6 +122,7 @@ import { createPreparedProviderHandoffContext, classifyProviderHandoffProjectionResults, createProviderHandoffPlan, + createPendingHandoffMarker, createProviderHandoffTransaction, decideProviderHandoffProfile, delegateTaskToChild, @@ -5101,6 +5102,59 @@ export class ClineProvider } handoffProtocol.advance({ type: "create-child" }) + // 5.5) Durable child write-ahead record (WAL before commit). The + // child's history record carries a secret-free pending-handoff + // marker (requested mode + explicit profile intent) so a crash + // between this write and the parent commit can always classify + // the child as a recoverable pre-commit orphan, and a crash + // after the parent commit still preserves the child's handoff + // identity. The full API configuration is deliberately NOT + // persisted: restart re-resolves it from the durable profile + // store by name, matching normal resumed-task behavior. The + // commit below is protocol-illegal until this record is durable. + try { + await this.taskHistoryStore.upsert({ + id: child.taskId, + rootTaskId: child.rootTaskId, + parentTaskId: parentTaskId, + number: child.taskNumber, + ts: Date.now(), + task: message, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + size: 0, + workspace: child.workspacePath, + mode: prepared.requestedMode, + ...(prepared.profile.name ? { apiConfigName: prepared.profile.name } : {}), + status: "active", + pendingHandoff: createPendingHandoffMarker({ prepared }), + }) + handoffProtocol.advance({ type: "wal-child" }) + } catch (walError) { + // Fail closed: without the durable child record the delegation + // commit must not run. Clean up the paused child, restore the + // parent, and surface the original failure. + handoffProtocol.advance({ type: "wal-child-failed" }) + this.log( + `[delegateParentAndOpenChild] Failed to persist child handoff record for ${child.taskId}: ${ + (walError as Error)?.message ?? String(walError) + }`, + ) + const rollback = await this.rollbackFailedDelegation(parentTaskId, child.taskId, transitionOwner) + handoffProtocol.advance({ type: "rollback-cleanup", ok: rollback.cleanupErrors.length === 0 }) + handoffProtocol.advance({ type: "rollback-restore", ok: rollback.restorationErrors.length === 0 }) + if (rollback.cleanupErrors.length + rollback.restorationErrors.length > 0) { + throw new AggregateError( + [walError, ...rollback.cleanupErrors, ...rollback.restorationErrors], + `[delegateParentAndOpenChild] Child handoff WAL rollback incomplete for parent ${parentTaskId}; original error: ${ + (walError as Error)?.message ?? String(walError) + }`, + ) + } + throw walError + } + // 6) Persist parent delegation metadata BEFORE the child starts writing. // atomicReadAndUpdate reads from the in-memory cache and writes back within a // single lock acquisition — no concurrent writer can slip between the read and @@ -5247,6 +5301,25 @@ export class ClineProvider this.explicitProfileClearChildIds.add(child.taskId) } + // 7.5) Best-effort finalization: the delegation is durable and the + // child's in-memory context is authoritative, so the write-ahead + // marker is no longer needed. A failed strip is non-fatal — + // restart reconciliation replays it for committed children. + try { + await this.taskHistoryStore.atomicReadAndUpdate(child.taskId, (historyItem) => ({ + ...historyItem, + pendingHandoff: undefined, + })) + handoffProtocol.advance({ type: "finalize-child-wal", ok: true }) + } catch (finalizeError) { + handoffProtocol.advance({ type: "finalize-child-wal", ok: false }) + this.log( + `[delegateParentAndOpenChild] Pending handoff marker for child ${child.taskId} left for restart replay: ${ + (finalizeError as Error)?.message ?? String(finalizeError) + }`, + ) + } + // 8) Start the child task immediately: the durable delegation is // committed and the child's execution context is authoritative, so // the child must never await the legacy projection. diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index d934a888bd..ec2fff24a1 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -256,6 +256,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // The child WAL record write is stubbed: fs/promises is fully mocked in + // this file, so the real store cannot perform disk I/O. + vi.spyOn(provider.taskHistoryStore, "upsert").mockResolvedValue([]) + // Mock providerSettingsManager ;(provider as any).providerSettingsManager = { saveConfig: vi.fn().mockResolvedValue("test-id"), @@ -1050,7 +1054,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { apiConfiguration: expect.anything(), }, }) - expect(atomicUpdateSpy).toHaveBeenCalledTimes(1) + // Two atomic store updates: the parent delegation commit, then the + // best-effort child pending-handoff marker strip. + expect(atomicUpdateSpy).toHaveBeenCalledTimes(2) // The prepared context became authoritative on the paused child after // the durable commit. diff --git a/src/core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts b/src/core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts new file mode 100644 index 0000000000..96cc89649c --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts @@ -0,0 +1,384 @@ +// npx vitest core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts +// +// Regression coverage for issue #921: delegation across parallel tabs with +// different view-local mode/profile state. Two ClineProvider instances share +// one durable profile store (like SecretStorage-backed profiles shared across +// webviews) while each holds its own view-local configuration. A concurrent +// provider's mutation between this provider's read-only preparation and its +// durable delegation commit must never leak into the child's execution +// context — the prepared immutable snapshot is authoritative. + +import { TaskScheduler } from "../../task/TaskScheduler" +import { + createPreparedProviderHandoffContext, + type PreparedProviderHandoffContext, +} from "../../task-persistence/providerHandoff" +import { delegateTaskToChild } from "../../task-persistence/taskLifecycle" +import { ClineProvider } from "../ClineProvider" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import type { HistoryItem, ProviderSettings } from "@roo-code/types" + +/** Mutable durable profile store shared by both provider "tabs". */ +interface SharedProfileStore { + currentApiConfigName: string | undefined + entries: Array<{ name: string; id: string; apiProvider: string; modelId: string }> + modeApiConfigId: Record + profiles: Record +} + +interface SharedWorld { + profileStore: SharedProfileStore + /** Shared legacy global settings (ContextProxy-backed in production). */ + globalProviderSettings: ProviderSettings + lockAcrossModes: boolean +} + +function buildSharedWorld(): SharedWorld { + return { + profileStore: { + currentApiConfigName: "profile-a", + entries: [ + { + name: "profile-a", + id: "profile-a-id", + apiProvider: providerIdentifiers.openai, + modelId: "gpt-test", + }, + ], + modeApiConfigId: {}, + profiles: { + "profile-a": { id: "profile-a-id", apiProvider: providerIdentifiers.openai, openAiApiKey: "sk-tab-a" }, + }, + }, + globalProviderSettings: { apiProvider: providerIdentifiers.openai, openAiApiKey: "sk-tab-a" }, + lockAcrossModes: false, + } +} + +/** Double of `ProviderSettingsManager.snapshotForHandoff` reading the shared store live. */ +function snapshotForHandoff(world: SharedWorld) { + return async (mode: string) => { + const store = world.profileStore + const modeApiConfigId = store.modeApiConfigId[mode] + let savedProfile: (ProviderSettings & { id: string; name: string }) | undefined + if (modeApiConfigId) { + const entry = Object.entries(store.profiles).find(([, profile]) => profile.id === modeApiConfigId) + if (entry) savedProfile = structuredClone({ name: entry[0], ...entry[1] }) + } + return { + currentApiConfigName: store.currentApiConfigName, + entries: structuredClone(store.entries), + modeApiConfigId, + savedProfile, + } + } +} + +/** Shared map-backed history store double for both providers. */ +function makeSharedStore() { + const records = new Map() + /** Every child WAL write, captured before the post-commit strip removes it. */ + const walWrites: HistoryItem[] = [] + return { + records, + walWrites, + get: (taskId: string) => records.get(taskId), + upsert: async (item: HistoryItem) => { + walWrites.push(item) + records.set(item.id, item) + return [item] + }, + atomicReadAndUpdate: async (taskId: string, updater: (current: HistoryItem) => HistoryItem) => { + const current = records.get(taskId) + if (!current) throw new Error(`[TaskHistoryStore] atomicReadAndUpdate: ${taskId} not found`) + records.set(taskId, updater(current)) + return [] + }, + readFresh: async (taskId: string) => + records.has(taskId) ? { kind: "found" as const, item: records.get(taskId)! } : { kind: "missing" as const }, + invalidate: async () => {}, + } +} + +type SharedStore = ReturnType + +const handoffPrototype = { + prepareProviderHandoffContext: ClineProvider.prototype["prepareProviderHandoffContext"], + projectPreparedProviderHandoffState: ClineProvider.prototype["projectPreparedProviderHandoffState"], + rollbackFailedDelegation: ClineProvider.prototype["rollbackFailedDelegation"], + restoreParentAfterFailedChildCreation: ClineProvider.prototype["restoreParentAfterFailedChildCreation"], + reconcileDelegationCommitFailure: ClineProvider.prototype["reconcileDelegationCommitFailure"], + delegateParentAndOpenChildUnlocked: ClineProvider.prototype["delegateParentAndOpenChildUnlocked"], + runDelegationTransition: ClineProvider.prototype["runDelegationTransition"], +} + +function makeWorldProvider( + world: SharedWorld, + store: SharedStore, + options: { + parentHistory: HistoryItem + parentTask: unknown + child: unknown + /** This provider's own view-local settings (tab-local in production). */ + viewSettings: ProviderSettings + createTaskGate?: () => Promise + }, +): ClineProvider { + const child = options.child as { + adoptHandoffExecutionContext: ReturnType + run: ReturnType + } + return { + delegationTransitionLocks: new Map>(), + delegationTransitionOwners: new Map(), + cancelledDelegationChildIds: new Set(), + explicitProfileClearChildIds: new Set(), + providerProfileMutationQueue: Promise.resolve(), + providerProfileMutationReservation: 0, + providerProfileMutationGeneration: 0, + providerProfileMutationSettledGeneration: 0, + profileMutationAbortControllers: new Set(), + nextProviderHandoffProjectionToken: 0, + _disposed: false, + context: { + workspaceState: { + get: (_key: string, defaultValue?: unknown) => + _key === "lockApiConfigAcrossModes" ? world.lockAcrossModes : defaultValue, + }, + }, + contextProxy: { + getProviderSettings: () => structuredClone(options.viewSettings), + }, + providerSettingsManager: { + snapshotForHandoff: snapshotForHandoff(world), + }, + taskHistoryStore: store, + getCurrentTask: vi.fn(() => options.parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockImplementation(async () => { + if (options.createTaskGate) await options.createTaskGate() + return options.child + }), + getTaskWithId: vi.fn(async (id: string) => ({ historyItem: store.records.get(id) })), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined), + deleteTaskWithId: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + ...handoffPrototype, + // The real prototype method needs the bounded profile-mutation queue; + // these tests stub the background projection instead. + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + } as unknown as ClineProvider +} + +function makeParent(id: string): HistoryItem { + return { + id, + number: 1, + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + mode: "code", + childIds: [], + } +} + +function makeTaskDouble(taskId: string) { + return { + taskId, + rootTaskId: undefined, + taskNumber: 2, + workspacePath: "/test/workspace", + flushPendingToolResultsToHistory: vi.fn().mockResolvedValue(true), + retrySaveApiConversationHistory: vi.fn(), + adoptHandoffExecutionContext: vi.fn(), + run: vi.fn().mockResolvedValue(undefined), + } +} + +describe("ClineProvider two-provider handoff concurrency (issue #921)", () => { + it("freezes the delegating tab's context against a concurrent other-tab profile mutation", async () => { + const world = buildSharedWorld() + const store = makeSharedStore() + store.records.set("parent-a", makeParent("parent-a")) + + let releaseChildCreation!: () => void + const childCreationGate = new Promise((resolve) => { + releaseChildCreation = resolve + }) + + const parentTask = makeTaskDouble("parent-a") + const childA = makeTaskDouble("child-a") + const tabASettings: ProviderSettings = { + apiProvider: providerIdentifiers.openai, + openAiApiKey: "sk-tab-a", + } + const providerA = makeWorldProvider(world, store, { + parentHistory: makeParent("parent-a"), + parentTask, + child: childA, + viewSettings: tabASettings, + // Pause provider A between its read-only preparation and its commit: + // exactly the window a concurrent tab's mutation must not leak into. + createTaskGate: async () => { + await childCreationGate + }, + }) + + // Tab B: a different view-local mode/profile with its own secret. + const tabBSettings: ProviderSettings = { + apiProvider: providerIdentifiers.openai, + openAiApiKey: "sk-tab-b", + } + world.profileStore.profiles["profile-b"] = { + id: "profile-b-id", + apiProvider: providerIdentifiers.openai, + openAiApiKey: "sk-tab-b", + } + world.profileStore.entries.push({ + name: "profile-b", + id: "profile-b-id", + apiProvider: providerIdentifiers.openai, + modelId: "gpt-test", + }) + const providerB = makeWorldProvider(world, store, { + parentHistory: makeParent("parent-b"), + parentTask: makeTaskDouble("parent-b"), + child: makeTaskDouble("child-b"), + viewSettings: tabBSettings, + }) + + // Provider A starts its delegation; it pauses inside child creation + // (preparation already captured the tab-A snapshot). + const delegationA = ClineProvider.prototype.delegateParentAndOpenChild.call(providerA, { + parentTaskId: "parent-a", + message: "Do tab-A work", + initialTodos: [], + mode: "code", + }) + await vi.waitFor(() => { + expect(vi.mocked(providerA["createTask"] as ReturnType).mock.calls.length).toBeGreaterThan(0) + }) + + // Concurrent tab-B activity: tab B "activates" profile-b — mutating the + // shared durable store and legacy global settings while A is paused — + // and then prepares its own handoff from the mutated world. + world.profileStore.currentApiConfigName = "profile-b" + world.globalProviderSettings = { apiProvider: providerIdentifiers.openai, openAiApiKey: "sk-tab-b" } + const preparedB: PreparedProviderHandoffContext = await providerB["prepareProviderHandoffContext"].call( + providerB, + "architect" as never, + ) + + // Release provider A: commit + child start happen after B's mutation. + releaseChildCreation() + const childResult = await delegationA + + expect(childResult).toBe(childA) + + // Tab A's child runs with the FROZEN tab-A context, never tab-B values. + const createTaskCall = vi.mocked(providerA["createTask"] as ReturnType).mock.calls[0] + expect(createTaskCall![3]).toMatchObject({ + initialStatus: "active", + startTask: false, + handoffExecutionContext: { + mode: "code", + apiConfigName: "profile-a", + }, + }) + const handedConfig = (createTaskCall![3] as { handoffExecutionContext: { apiConfiguration: ProviderSettings } }) + .handoffExecutionContext.apiConfiguration + expect(handedConfig.openAiApiKey).toBe("sk-tab-a") + + expect(childA.adoptHandoffExecutionContext).toHaveBeenCalledWith({ + mode: "code", + apiConfigName: "profile-a", + apiConfiguration: expect.objectContaining({ openAiApiKey: "sk-tab-a" }), + }) + expect(childA.run).toHaveBeenCalledTimes(1) + + // The durable child WAL record carries tab A's identity, not tab B's. + const walRecord = store.walWrites[0] + expect(walRecord?.pendingHandoff).toEqual({ + kind: "set", + version: 1, + mode: "code", + profileName: "profile-a", + }) + expect(walRecord?.apiConfigName).toBe("profile-a") + const parentRecord = store.records.get("parent-a") + expect(parentRecord?.status).toBe("delegated") + expect(parentRecord).toEqual(delegateTaskToChild(makeParent("parent-a"), "child-a")) + + // Tab B's own prepared context is its own: cross-provider isolation is + // symmetric and B's snapshot is unaffected by A's frozen context. + expect(preparedB.requestedMode).toBe("architect") + expect(preparedB.profile.name).toBe("profile-b") + expect(preparedB.apiConfiguration.openAiApiKey).toBe("sk-tab-b") + }) + + it("keeps an explicit profile clear against a concurrent other-tab profile set", async () => { + const world = buildSharedWorld() + // Tab A's view has no current profile at all: an explicit clear handoff. + world.profileStore.currentApiConfigName = undefined + + const store = makeSharedStore() + store.records.set("parent-a", makeParent("parent-a")) + + let releaseChildCreation!: () => void + const childCreationGate = new Promise((resolve) => { + releaseChildCreation = resolve + }) + + const childA = makeTaskDouble("child-a") + const providerA = makeWorldProvider(world, store, { + parentHistory: makeParent("parent-a"), + parentTask: makeTaskDouble("parent-a"), + child: childA, + viewSettings: { apiProvider: providerIdentifiers.openai, openAiApiKey: "sk-tab-a" }, + createTaskGate: async () => { + await childCreationGate + }, + }) + + const delegationA = ClineProvider.prototype.delegateParentAndOpenChild.call(providerA, { + parentTaskId: "parent-a", + message: "Do tab-A work", + initialTodos: [], + mode: "code", + }) + await vi.waitFor(() => { + expect(vi.mocked(providerA["createTask"] as ReturnType).mock.calls.length).toBeGreaterThan(0) + }) + + // Concurrent tab-B set of a named profile must not turn the clear handoff + // into a set handoff. + world.profileStore.currentApiConfigName = "profile-b" + + releaseChildCreation() + await delegationA + + const createTaskCall = vi.mocked(providerA["createTask"] as ReturnType).mock.calls[0] + expect(createTaskCall![3]).toMatchObject({ + handoffExecutionContext: { + mode: "code", + apiConfigName: undefined, + }, + }) + // The durable WAL record preserves the explicit clear intent (captured + // at write time; the post-commit strip removes the marker afterward). + const walRecord = store.walWrites[0] + expect(walRecord?.pendingHandoff).toEqual({ + kind: "clear", + version: 1, + mode: "code", + }) + expect(walRecord?.apiConfigName).toBeUndefined() + }) +}) From 330cd8a3254181bb68e3c70df392ab597ed99e66 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 8 Sep 2026 00:55:28 +0000 Subject: [PATCH 15/17] fix(delegation): validate WAL identity and unblock child start --- docs/architecture/task-lifecycle-model.md | 2 +- packages/types/src/history.ts | 10 +- scripts/check-provider-handoff.ts | 52 +++-- .../ClineProvider.delegation.spec.ts | 181 +++++++++++++++++- src/__tests__/helpers/provider-stub.ts | 2 + .../TaskHistoryStore.reconciliation.spec.ts | 10 + .../__tests__/providerHandoff.spec.ts | 52 +++++ src/core/task-persistence/providerHandoff.ts | 34 ++-- src/core/webview/ClineProvider.ts | 78 +++++--- .../ClineProvider.apiHandlerRebuild.spec.ts | 8 +- 10 files changed, 366 insertions(+), 63 deletions(-) diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index ad2dff78c3..decf414a35 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -74,7 +74,7 @@ After preparation, the parent is removed. The paused child is created from the p Before the commit, the child's own history record is written as a durable write-ahead record. It carries a secret-free `pendingHandoff` marker (`HistoryItem.pendingHandoff`, versioned, discriminated `set`/`preserve`/`clear`) holding the requested mode and the explicit profile projection intent. The full API configuration is deliberately NOT persisted: restart re-resolves it from the durable profile store by name, exactly like normal resumed tasks. The protocol makes the delegation commit illegal until this record is durable (`wal-child` before `commit-delegation`; committing from `child-created` is rejected as `wal-required`). -The delegation is then durably committed through `TaskHistoryStore.atomicReadAndUpdate`. That atomic commit is the single lifecycle commit boundary. After the commit, context activation moves execution authority to the child and the marker is stripped best-effort; a failed strip is replayed by restart reconciliation and never blocks the child from starting. Legacy global state, the profile store, and publication are best-effort projections. They run strictly after the commit. They can never undo the commit or block the child from starting. +The delegation is then durably committed through `TaskHistoryStore.atomicReadAndUpdate`. That atomic commit is the single lifecycle commit boundary. After the commit, context activation moves execution authority to the child and the child is scheduled immediately. The marker strip then starts as handled background work. Store-lock contention, storage delay, write-through callbacks, or a strip that never settles can never delay scheduling or the delegation call. A failed or never-settling strip leaves the marker on disk for restart reconciliation. Legacy global state, the profile store, and publication are best-effort projections. They run strictly after the commit. They can never undo the commit or block the child from starting. This yields a crash/restart invariant for the handoff identity: the child's durable record always precedes the parent's durable pointer, so a crash between the two writes leaves a recoverable trail instead of a parent pointing at a child whose identity was lost. Startup reconciliation in `TaskHistoryStore.reconcilePendingHandoffRecords` replays it: diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 9b4b55b7be..1e5ca6ac1a 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -51,19 +51,21 @@ export const pendingHandoffSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("set"), version: z.literal(1), - mode: z.string(), - profileName: z.string(), + mode: z.string().min(1), + // Parity with `isValidPendingHandoff`: a `set` marker without a + // durable identity must never parse as valid. + profileName: z.string().min(1), }), z.object({ kind: z.literal("preserve"), version: z.literal(1), - mode: z.string(), + mode: z.string().min(1), profileName: z.string().optional(), }), z.object({ kind: z.literal("clear"), version: z.literal(1), - mode: z.string(), + mode: z.string().min(1), }), ]) diff --git a/scripts/check-provider-handoff.ts b/scripts/check-provider-handoff.ts index b3b6ac19e8..f5c73b05ae 100644 --- a/scripts/check-provider-handoff.ts +++ b/scripts/check-provider-handoff.ts @@ -214,21 +214,17 @@ function candidateEvents(ms: ModelState): Candidate[] { case "delegation-committed": return [{ name: "activate-context", event: { type: "activate-context", generation } }] case "context-active": { - // Production finalizes the child's write-ahead marker (best-effort, - // both outcomes modeled) before the child starts, then starts the - // child immediately: the child must never await the legacy - // projection; the projection itself is fire-and-forget background - // work that may settle before OR after the child started. Both - // orders (and a projection that never completes before - // publication) are protocol states. - const candidates: Candidate[] = [] + // Production schedules the child immediately after activation; the + // write-ahead marker strip and the legacy projection are both + // fire-and-forget background work that may settle before OR after + // the child started (or never settle before publication). All + // orders are protocol states. + const candidates: Candidate[] = [{ name: "start-child", event: { type: "start-child" } }] if (p.childWal === "durable") { candidates.push( { name: "finalize-child-wal:ok", event: { type: "finalize-child-wal", ok: true } }, { name: "finalize-child-wal:fail", event: { type: "finalize-child-wal", ok: false } }, ) - } else { - candidates.push({ name: "start-child", event: { type: "start-child" } }) } if (p.projection === "original") { candidates.push( @@ -249,9 +245,18 @@ function candidateEvents(ms: ModelState): Candidate[] { return candidates } case "child-running": { - // A projection still unresolved when the child started may settle - // while the child runs; publication is policy-gated and independent. + // A marker strip or projection still unresolved when the child + // started may settle while the child runs; publication is + // policy-gated and independent. const candidates: Candidate[] = [{ name: "publish", event: { type: "publish" } }] + if (p.childWal === "durable") { + // The background marker strip may settle (either outcome) after + // the child started, or never settle before publication. + candidates.push( + { name: "finalize-child-wal:ok", event: { type: "finalize-child-wal", ok: true } }, + { name: "finalize-child-wal:fail", event: { type: "finalize-child-wal", ok: false } }, + ) + } if (p.projection === "original") { candidates.push( { @@ -599,11 +604,17 @@ function violations(ms: ModelState): string[] { if (p.projection === "original" && e.projectionWriteStarted) { found.push("a started projection write vanished without settling") } - // Production always attempts the best-effort marker strip between - // activation and child start, so a settled state has either finalized - // the marker or left it for restart reconciliation. - if (p.childWal !== "finalized" && p.childWal !== "finalize-failed") { - found.push("settled without finalizing the child write-ahead marker") + // Production starts the best-effort marker strip right after the + // child is scheduled, so a settled state has either finalized the + // marker or left it for restart reconciliation (a failed strip, or a + // background strip that never settled). + if (p.childWal !== "finalized" && p.childWal !== "finalize-failed" && p.childWal !== "durable") { + found.push("settled without starting the child write-ahead marker strip") + } + // A strip that never settled must have left the marker on disk for + // restart reconciliation. + if (p.childWal === "durable" && !e.childWalRecord) { + found.push("settled with an unresolved marker strip but no marker on disk for restart replay") } // A started-but-stale projection never overwrites a newer generation's // publication: stale publication derives from the child's prepared @@ -661,6 +672,12 @@ function landmarksOf(ms: ModelState): string[] { // settlement or child start. marks.push("settlement:wal-restart-replay") } + if (e.childStarted && p.childWal === "durable" && (p.phase === "child-running" || p.phase === "settled")) { + // The child was scheduled before the marker strip settled (or the + // strip never settled): finalization is background work and the + // marker remains for restart reconciliation. + marks.push("start:wal-finalize-pending") + } if (p.failure?.boundary === "delegation-commit" && p.failure.commitDurability === "uncommitted") { marks.push("commit-ambiguity:observed-uncommitted") } @@ -709,6 +726,7 @@ const REQUIRED_LANDMARKS = [ "commit-ambiguity:observed-committed-settled", "commit-ambiguity:incoherent-degraded", "start:projection-unresolved", + "start:wal-finalize-pending", "projection:preserve-pinned-identity", "settlement:projection-still-original", "wal:durable-before-commit", diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index d10e8b30ca..b620d91653 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -146,6 +146,7 @@ const makeProviderStub = (partial: Record): ClineProvider => delegationTransitionOwners: new Map(), cancelledDelegationChildIds: new Set(), explicitProfileClearChildIds: new Set(), + durableProfileClearByTaskId: new Map>(), providerProfileMutationQueue: Promise.resolve(), providerProfileMutationReservation: 0, providerProfileMutationGeneration: 0, @@ -2145,12 +2146,160 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { await delegation expect(await completion).toBe(false) // The completion's store read happened only after the delegation - // committed and its marker strip finalized inside the delegation. + // committed and its marker strip started inside the delegation, after + // the child was scheduled. expect(order[0]).toBe("committed") expect(order[1]).toBe("finalize-child-wal") expect(order[2]).toBe("read:parent-1") }) + it("schedules the child while the marker strip is still pending and settles it in the background", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const order: string[] = [] + + let releaseFinalize!: () => void + const finalizeGate = new Promise((resolve) => { + releaseFinalize = resolve + }) + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async (taskId: string) => { + if (taskId === "child-1") { + // The best-effort background marker strip: gated so it stays + // pending past the delegation's completion. + order.push("finalize-started") + await finalizeGate + order.push("finalize-settled") + return [] + } + order.push("committed") + return [] + }), + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + deleteTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "First", + initialTodos: [], + mode: "code", + }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + + // The child was scheduled and the delegation completed while the + // marker strip was still pending. + expect(child.run).toHaveBeenCalledTimes(1) + expect(order).toEqual(["committed", "finalize-started"]) + + releaseFinalize() + await (provider as unknown as { providerHandoffFinalizationCompletion?: Promise }) + .providerHandoffFinalizationCompletion + expect(order).toEqual(["committed", "finalize-started", "finalize-settled"]) + }) + + it("completes the delegation even when the marker strip never settles", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async (taskId: string) => { + if (taskId === "child-1") { + // A strip whose storage write never settles: lock + // contention, a hung store, or a lost write-through. + return new Promise(() => {}) + } + return [] + }), + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + deleteTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "First", + initialTodos: [], + mode: "code", + }), + ).resolves.toBe(child) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + expect(child.run).toHaveBeenCalledTimes(1) + }) + + it("keeps a rejected marker strip non-fatal and logged for restart replay", async () => { + const parentTask = makeParentTask() + const child = makeChildTask("child-1") + const log = vi.fn() + const taskHistoryStore = makeStoreStub({ + atomicReadAndUpdate: vi.fn(async (taskId: string) => { + if (taskId === "child-1") { + throw new Error("storage rejected the strip") + } + return [] + }), + }) + + const provider = makeProviderStub({ + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + prepareProviderHandoffContext: makePreparationStub(makePreparedHandoff()), + projectPreparedProviderHandoffState: vi.fn().mockResolvedValue({ ok: true }), + deleteTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + log, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + }) + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "First", + initialTodos: [], + mode: "code", + }), + ).resolves.toBe(child) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + expect(child.run).toHaveBeenCalledTimes(1) + + await (provider as unknown as { providerHandoffFinalizationCompletion?: Promise }) + .providerHandoffFinalizationCompletion + expect(log).toHaveBeenCalledWith(expect.stringContaining("left for restart replay")) + expect(log).toHaveBeenCalledWith(expect.stringContaining("storage rejected the strip")) + }) + it("starts the child after a timed-out projection and ignores the late completion", async () => { vi.useFakeTimers() try { @@ -2649,14 +2798,19 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // store identity was cleared and the resumed child still carries no // sticky profile, so the clear is reconstructed for publication. const resumedChild = { taskId: "child-1", taskApiConfigName: undefined } + const getCurrentProfileName = vi.fn().mockResolvedValue(undefined) const provider = makeProviderStub({ log: vi.fn(), getCurrentTask: vi.fn(() => resumedChild), - providerSettingsManager: { getCurrentProfileName: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { getCurrentProfileName }, }) await expect(ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-1")).resolves.toBe( true, ) + await expect(ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-1")).resolves.toBe( + true, + ) + expect(getCurrentProfileName).toHaveBeenCalledTimes(1) // A durable identity means no explicit clear: the ordinary default // fallback is unchanged (including fresh installs). @@ -2668,6 +2822,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { await expect( ClineProvider.prototype["isExplicitProfileClearInForce"].call(withIdentity, "child-1"), ).resolves.toBe(false) + await expect( + ClineProvider.prototype["isExplicitProfileClearInForce"].call(withIdentity, "child-1"), + ).resolves.toBe(false) + expect(withIdentity["providerSettingsManager"].getCurrentProfileName).toHaveBeenCalledTimes(1) // A child that later gained a sticky profile is no longer cleared. const profiledChild = { taskId: "child-1", taskApiConfigName: "chosen" } @@ -2691,6 +2849,25 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { ).resolves.toBe(false) }) + it("invalidates cached durable clear reconstruction after a successful profile mutation", async () => { + const resumedChild = { taskId: "child-1", taskApiConfigName: undefined } + const getCurrentProfileName = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValue("chosen") + const provider = makeProviderStub({ + log: vi.fn(), + getCurrentTask: vi.fn(() => resumedChild), + providerSettingsManager: { getCurrentProfileName }, + }) + + await expect(ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-1")).resolves.toBe( + true, + ) + await ClineProvider.prototype["enqueueProviderProfileMutation"].call(provider, async () => undefined) + await expect(ClineProvider.prototype["isExplicitProfileClearInForce"].call(provider, "child-1")).resolves.toBe( + false, + ) + expect(getCurrentProfileName).toHaveBeenCalledTimes(2) + }) + it("cleans a removed child's explicit-clear markers when it leaves the stack", async () => { const removed = { taskId: "child-1", diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 800d48ac68..42b8cd122f 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -7,6 +7,7 @@ type ProviderStubFields = { delegationTransitionOwners?: Map cancelledDelegationChildIds?: Set explicitProfileClearChildIds?: Set + durableProfileClearByTaskId?: Map> log?: ReturnType taskHistoryStore?: { get: (id: string) => unknown } taskRegistry?: TaskRegistry @@ -44,6 +45,7 @@ export function makeProviderStub(stub: T): ClineProvider { s.delegationTransitionOwners ??= new Map() s.cancelledDelegationChildIds ??= new Set() s.explicitProfileClearChildIds ??= new Set() + s.durableProfileClearByTaskId ??= new Map() s.log ??= vi.fn() s.taskHistoryStore ??= { get: () => undefined } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts index 32ee4c0654..29caf31aeb 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts @@ -1107,6 +1107,14 @@ describe("TaskHistoryStore pendingHandoff reconciliation", () => { }) it("leaves ambiguous WAL records untouched (fail-safe guards)", async () => { + const emptyMode = makeItem({ + id: "wal-guard-empty-mode", + parentTaskId: "wal-guard-parent", + status: "active", + // Disk data can bypass the schema. Reconciliation must not delete a + // child when its current-version marker has no executable mode. + pendingHandoff: { kind: "clear", version: 1, mode: "" }, + }) const unknownVersion = makeItem({ id: "wal-guard-version", parentTaskId: "wal-guard-parent", @@ -1144,6 +1152,7 @@ describe("TaskHistoryStore pendingHandoff reconciliation", () => { // the sweep must treat conservatively. await seedItems([ makeItem({ id: "wal-guard-parent", status: "active" }), + emptyMode, unknownVersion, interruptedChild, noParent, @@ -1155,6 +1164,7 @@ describe("TaskHistoryStore pendingHandoff reconciliation", () => { // Every ambiguous record survives; nothing was deleted. for (const id of [ + "wal-guard-empty-mode", "wal-guard-version", "wal-guard-interrupted", "wal-guard-orphan-root", diff --git a/src/core/task-persistence/__tests__/providerHandoff.spec.ts b/src/core/task-persistence/__tests__/providerHandoff.spec.ts index a056bd1848..5d8f6a9852 100644 --- a/src/core/task-persistence/__tests__/providerHandoff.spec.ts +++ b/src/core/task-persistence/__tests__/providerHandoff.spec.ts @@ -57,6 +57,12 @@ describe("provider handoff contract", () => { expect(deriveProviderHandoffProfileIntent({ source: "locked-current", name: undefined })).toEqual({ kind: "clear", }) + // An empty name carries no durable identity: it is a clear, never a + // `set` marker that `isValidPendingHandoff` would reject. + expect(deriveProviderHandoffProfileIntent({ source: "saved", name: "" })).toEqual({ kind: "clear" }) + expect(deriveProviderHandoffProfileIntent({ source: "locked-current", name: "" })).toEqual({ kind: "clear" }) + // Names are exact identities: a whitespace-only name stays a set. + expect(deriveProviderHandoffProfileIntent({ source: "saved", name: " " })).toEqual({ kind: "set", name: " " }) }) it("carries the derived intent on the prepared context", () => { @@ -97,6 +103,26 @@ describe("provider handoff contract", () => { }), }), ).toEqual({ kind: "clear", version: 1, mode: "code" }) + // An empty profile name can never produce a `set` marker. + expect( + createPendingHandoffMarker({ + prepared: createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "saved", name: "", id: "empty-id" }, + ...base, + }), + }), + ).toEqual({ kind: "clear", version: 1, mode: "code" }) + // A whitespace-only name is a durable identity exactly as given. + expect( + createPendingHandoffMarker({ + prepared: createPreparedProviderHandoffContext({ + requestedMode: "code", + profile: { source: "saved", name: " ", id: "space-id" }, + ...base, + }), + }), + ).toEqual({ kind: "set", version: 1, mode: "code", profileName: " " }) // The marker never carries configuration or secret-shaped fields. const marker = createPendingHandoffMarker({ prepared: createPreparedProviderHandoffContext({ @@ -117,8 +143,11 @@ describe("provider handoff contract", () => { // Unknown version: fail safe, leave untouched. expect(isValidPendingHandoff({ kind: "clear", version: 2, mode: "code" })).toBe(false) expect(isValidPendingHandoff({ kind: "set", version: 1, mode: "code" })).toBe(false) + expect(isValidPendingHandoff({ kind: "set", version: 1, mode: "code", profileName: "" })).toBe(false) + expect(isValidPendingHandoff({ kind: "set", version: 1, mode: "code", profileName: " " })).toBe(true) expect(isValidPendingHandoff({ kind: "unknown", version: 1, mode: "code" })).toBe(false) expect(isValidPendingHandoff({ kind: "clear", version: 1 })).toBe(false) + expect(isValidPendingHandoff({ kind: "clear", version: 1, mode: "" })).toBe(false) expect(isValidPendingHandoff(null)).toBe(false) expect(isValidPendingHandoff("clear")).toBe(false) }) @@ -839,6 +868,29 @@ describe("provider handoff transaction protocol", () => { expect(drifted.state.rollbackFailures).toEqual([]) }) + it("accepts a background marker strip after the child started, once only", () => { + const { states } = drive(initialProviderHandoffState(), [ + ...happyPath.slice(0, 6), + { type: "start-child" }, + { type: "finalize-child-wal", ok: true }, + { type: "finalize-child-wal", ok: true }, + ]) + expect(states[6]).toMatchObject({ phase: "context-active", childWal: "durable" }) + // The strip is background work: it settles only after the child started. + expect(states[7]).toMatchObject({ phase: "child-running", childPresence: "running", childWal: "durable" }) + expect(states[8]).toMatchObject({ phase: "child-running", childWal: "finalized" }) + // Single-shot: a second strip is rejected and changes nothing. + expect(states[9]).toMatchObject({ phase: "child-running", childWal: "finalized" }) + + // A rejected late strip stays visible for restart reconciliation. + const failedLate = drive(initialProviderHandoffState(), [ + ...happyPath.slice(0, 6), + { type: "start-child" }, + { type: "finalize-child-wal", ok: false }, + ]) + expect(failedLate.states[8]).toMatchObject({ phase: "child-running", childWal: "finalize-failed" }) + }) + it("carries no secrets or configuration in protocol state", () => { const { states } = drive(initialProviderHandoffState(), [ ...happyPath.slice(0, 6), diff --git a/src/core/task-persistence/providerHandoff.ts b/src/core/task-persistence/providerHandoff.ts index 94cf77a148..acd64ba333 100644 --- a/src/core/task-persistence/providerHandoff.ts +++ b/src/core/task-persistence/providerHandoff.ts @@ -98,13 +98,17 @@ export type ProviderHandoffProfileIntent = * Derive the explicit projection intent from a prepared profile decision. * A named profile projects as `set` — except under workspace profile locking, * where the identity is user-pinned and the projection must not rewrite it - * (`preserve`). A profile without a name is an explicit `clear`. + * (`preserve`). A profile without a usable name (absent or empty string) is an + * explicit `clear`: an empty name carries no durable identity, and a `set` + * marker with an empty profile name would be rejected by + * `isValidPendingHandoff` and by the persisted schema. Names are exact + * identities, so a whitespace-only name stays a `set` intent. */ export function deriveProviderHandoffProfileIntent(profile: { source: ProviderHandoffProfileDecision["source"] name: string | undefined }): ProviderHandoffProfileIntent { - if (profile.name === undefined) return { kind: "clear" } + if (!profile.name) return { kind: "clear" } if (profile.source === "locked-current") return { kind: "preserve" } return { kind: "set", name: profile.name } } @@ -184,7 +188,7 @@ export function isValidPendingHandoff(value: unknown): value is PendingHandoff { if (!value || typeof value !== "object") return false const candidate = value as Record if (candidate.version !== PENDING_HANDOFF_VERSION) return false - if (typeof candidate.mode !== "string") return false + if (typeof candidate.mode !== "string" || candidate.mode.length === 0) return false switch (candidate.kind) { case "set": return typeof candidate.profileName === "string" && candidate.profileName.length > 0 @@ -396,8 +400,10 @@ export interface ProviderHandoffState { * Durability of the child-side write-ahead handoff record. The * delegation commit is legal only once the child's pending-handoff * record is durable ("durable"); "finalized"/"finalize-failed" record - * the best-effort post-commit marker strip (restart replay covers a - * failure). + * the best-effort post-commit marker strip, which production starts as + * background work after the child is scheduled. "durable" persisting + * after activation means the strip has not settled yet; restart replay + * covers a failed or never-settling strip. */ readonly childWal: "none" | "durable" | "finalized" | "finalize-failed" readonly projection: ProviderHandoffProjectionState @@ -424,8 +430,10 @@ export type ProviderHandoffEvent = | { type: "commit-delegation" } | { type: "commit-failed" } /** - * Best-effort post-commit strip of the child's pending-handoff marker. A - * failure is non-fatal: restart reconciliation replays the strip. + * Best-effort background strip of the child's pending-handoff marker, + * started after the child is scheduled. It may settle before or after + * the child started. A failure is non-fatal: restart reconciliation + * replays the strip. */ | { type: "finalize-child-wal"; ok: boolean } | { @@ -611,12 +619,16 @@ export function applyProviderHandoffEvent( if (event.generation !== state.generation) return reject(state, "generation-mismatch") return accept({ ...state, phase: "context-active", contextAuthority: "child" }) case "finalize-child-wal": - // Best-effort marker strip between activation and child start. A - // failure stays visible ("finalize-failed") and is replayed by the - // restart reconciliation; it never blocks the child from starting. - if (state.phase !== "context-active" || state.childWal !== "durable") { + // Best-effort background marker strip: production starts it after + // the child is scheduled, so it may settle while the protocol is + // still in context-active OR after the child already started + // (child-running). Single-shot either way. A failure stays visible + // ("finalize-failed") and is replayed by the restart + // reconciliation; it never blocks the child from starting. + if (state.phase !== "context-active" && state.phase !== "child-running") { return reject(state, "unexpected-event") } + if (state.childWal !== "durable") return reject(state, "unexpected-event") return accept({ ...state, childWal: event.ok ? "finalized" : "finalize-failed" }) case "project-legacy": // Legacy projection is background work: it may settle while the diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 667dde1d50..188f9366aa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -343,6 +343,8 @@ export class ClineProvider * In-memory only; bounded by no-profile delegations in this session. */ private explicitProfileClearChildIds = new Set() + /** Per-task durable clear reconstruction, including both true and false results. */ + private durableProfileClearByTaskId = new Map>() /** * Completion hook for the most recent background handoff projection. @@ -351,6 +353,15 @@ export class ClineProvider */ private providerHandoffProjectionCompletion?: Promise + /** + * Completion hook for the most recent background child-WAL finalization + * (the post-commit pending-handoff marker strip). Deterministic + * test/observability access: awaiting this promise observes settlement + * without polling or sleeps. A strip that never settles keeps this + * promise pending forever without affecting the delegation. + */ + private providerHandoffFinalizationCompletion?: Promise + /** * Protocol bookkeeping for the delegation in flight, advanced at semantic * landmarks by `delegateParentAndOpenChild`. Purely observational: the @@ -481,6 +492,9 @@ export class ClineProvider if (admittedGeneration === undefined) { return } + // A successful profile mutation can change the durable identity. + // Force later publications to reconstruct it once for their task. + this.durableProfileClearByTaskId.clear() this.providerProfileMutationSettledGeneration = admittedGeneration this.supersedeStaleProviderHandoffProjection(admittedGeneration) }, @@ -752,10 +766,15 @@ export class ClineProvider if (currentTask?.taskId !== currentTaskId || currentTask.taskApiConfigName !== undefined) { return false } - const durableIdentity = await this.providerSettingsManager - .getCurrentProfileName() - .catch(() => "unreadable" as const) - return durableIdentity === undefined + let durableClear = this.durableProfileClearByTaskId.get(currentTaskId) + if (!durableClear) { + durableClear = this.providerSettingsManager + .getCurrentProfileName() + .then((durableIdentity) => durableIdentity === undefined) + .catch(() => false) + this.durableProfileClearByTaskId.set(currentTaskId, durableClear) + } + return durableClear } /** @@ -779,6 +798,7 @@ export class ClineProvider */ private invalidateProviderHandoffProjectionState(childTaskId: string): void { this.explicitProfileClearChildIds.delete(childTaskId) + this.durableProfileClearByTaskId.delete(childTaskId) this.providerHandoffProjectionTargets?.delete(childTaskId) const marker = this.staleProviderHandoffProjection if (marker?.childTaskId === childTaskId) { @@ -5301,30 +5321,38 @@ export class ClineProvider this.explicitProfileClearChildIds.add(child.taskId) } - // 7.5) Best-effort finalization: the delegation is durable and the - // child's in-memory context is authoritative, so the write-ahead - // marker is no longer needed. A failed strip is non-fatal — - // restart reconciliation replays it for committed children. - try { - await this.taskHistoryStore.atomicReadAndUpdate(child.taskId, (historyItem) => ({ + // 7.5) Start the child task immediately: the durable delegation is + // committed and the child's execution context is authoritative, so + // the child must never await marker finalization or the legacy + // projection. + scheduleTask(this.taskScheduler, child, "delegateParentAndOpenChild") + handoffProtocol.advance({ type: "start-child" }) + + // 8) Best-effort finalization as handled background work, started only + // after the child is scheduled: the write-ahead marker is no longer + // needed once the delegation is durable and the child's in-memory + // context is authoritative. Store-lock contention, storage delay, + // write-through callbacks, or a strip that never settles can never + // block scheduling or this method's completion. A rejected strip is + // logged and the marker is left for restart reconciliation. + const finalizeCompletion = this.taskHistoryStore + .atomicReadAndUpdate(child.taskId, (historyItem) => ({ ...historyItem, pendingHandoff: undefined, })) - handoffProtocol.advance({ type: "finalize-child-wal", ok: true }) - } catch (finalizeError) { - handoffProtocol.advance({ type: "finalize-child-wal", ok: false }) - this.log( - `[delegateParentAndOpenChild] Pending handoff marker for child ${child.taskId} left for restart replay: ${ - (finalizeError as Error)?.message ?? String(finalizeError) - }`, - ) - } - - // 8) Start the child task immediately: the durable delegation is - // committed and the child's execution context is authoritative, so - // the child must never await the legacy projection. - scheduleTask(this.taskScheduler, child, "delegateParentAndOpenChild") - handoffProtocol.advance({ type: "start-child" }) + .then(() => { + handoffProtocol.advance({ type: "finalize-child-wal", ok: true }) + }) + .catch((finalizeError) => { + handoffProtocol.advance({ type: "finalize-child-wal", ok: false }) + this.log( + `[delegateParentAndOpenChild] Pending handoff marker for child ${child.taskId} left for restart replay: ${ + (finalizeError as Error)?.message ?? String(finalizeError) + }`, + ) + }) + this.providerHandoffFinalizationCompletion = finalizeCompletion + void finalizeCompletion // 9) Best-effort legacy projection of the prepared context onto global // state and the durable profile store — fire-and-forget background diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index ec2fff24a1..cb72ad605d 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -105,15 +105,17 @@ vi.mock("../../task/Task", () => ({ isCompleteTaskHandoffExecutionContext: (execution: unknown) => { const candidate = execution as | { mode?: unknown; apiConfigName?: unknown; apiConfiguration?: unknown } + | null | undefined return ( + candidate !== null && candidate !== undefined && typeof candidate === "object" && typeof candidate.mode === "string" && candidate.mode.length > 0 && - typeof candidate.apiConfigName === "string" && - candidate.apiConfigName.length > 0 && - candidate.apiConfiguration !== undefined + (candidate.apiConfigName === undefined || typeof candidate.apiConfigName === "string") && + typeof candidate.apiConfiguration === "object" && + candidate.apiConfiguration !== null ) }, Task: vi.fn().mockImplementation(function (options) { From bfb5ee291472857b1a2305aad05b732d5056b030 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 8 Sep 2026 02:07:48 +0000 Subject: [PATCH 16/17] fix(delegation): preserve profile clear intent through WAL recovery --- docs/architecture/task-lifecycle-model.md | 2 +- packages/types/src/__tests__/history.test.ts | 13 ++- packages/types/src/history.ts | 2 +- src/core/task-persistence/TaskHistoryStore.ts | 8 +- .../TaskHistoryStore.reconciliation.spec.ts | 22 +++++ .../__tests__/providerHandoff.spec.ts | 5 +- src/core/task-persistence/providerHandoff.ts | 5 +- src/core/webview/ClineProvider.ts | 89 +++++++++++-------- .../ClineProvider.apiHandlerRebuild.spec.ts | 27 ++++++ 9 files changed, 128 insertions(+), 45 deletions(-) diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index decf414a35..7c80ca29dc 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -78,7 +78,7 @@ The delegation is then durably committed through `TaskHistoryStore.atomicReadAnd This yields a crash/restart invariant for the handoff identity: the child's durable record always precedes the parent's durable pointer, so a crash between the two writes leaves a recoverable trail instead of a parent pointing at a child whose identity was lost. Startup reconciliation in `TaskHistoryStore.reconcilePendingHandoffRecords` replays it: -- committed (the parent durably delegates to this child): the stale marker is stripped; the child's persisted `mode`/`apiConfigName` drive the normal resume path, and the existing active-child repair handles the never-started child. +- committed (the parent durably delegates to this child): `set` and `preserve` markers are stripped. A `clear` marker remains until its profile projection succeeds, so restart reconstruction does not reuse an old global identity. The child's persisted `mode`/`apiConfigName` drive the normal resume path, and the existing active-child repair handles the never-started child. - pre-commit orphan (guarded by valid marker version, lineage to a present parent record, pre-start child status, no delegation bookkeeping of its own, and no matching parent delegation): the child record and its task directory are removed. The guards are deliberately conservative: a false negative only leaves a stale record on disk, while a false positive would delete user data. Ambiguous records are left untouched. The invariant covers the mode/profile identity — it does not claim that a frozen secret-bearing API configuration survives restart, and it does not replace the repair journal or `readFresh` reconciliation for parent-record ambiguity. diff --git a/packages/types/src/__tests__/history.test.ts b/packages/types/src/__tests__/history.test.ts index 75fcf1d86c..3ef8678f49 100644 --- a/packages/types/src/__tests__/history.test.ts +++ b/packages/types/src/__tests__/history.test.ts @@ -1,4 +1,15 @@ -import { historyItemSchema, pendingTaskActionSchema } from "../history.js" +import { historyItemSchema, pendingHandoffSchema, pendingTaskActionSchema } from "../history.js" + +describe("pendingHandoffSchema", () => { + it("rejects an empty preserve profile name but keeps exact whitespace identities", () => { + expect( + pendingHandoffSchema.safeParse({ kind: "preserve", version: 1, mode: "code", profileName: "" }).success, + ).toBe(false) + expect( + pendingHandoffSchema.safeParse({ kind: "preserve", version: 1, mode: "code", profileName: " " }).success, + ).toBe(true) + }) +}) describe("pendingTaskActionSchema", () => { it("accepts create and finish subtask actions", () => { diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 1e5ca6ac1a..68d465d6bb 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -60,7 +60,7 @@ export const pendingHandoffSchema = z.discriminatedUnion("kind", [ kind: z.literal("preserve"), version: z.literal(1), mode: z.string().min(1), - profileName: z.string().optional(), + profileName: z.string().min(1).optional(), }), z.object({ kind: z.literal("clear"), diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 66e528739f..ece9801018 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -560,8 +560,12 @@ export class TaskHistoryStore { if (!parent) continue if (parent.status === "delegated" && parent.awaitingChildId === item.id) { - // Committed: the parent delegation is durable. Strip the stale - // marker; the child's own mode/apiConfigName fields remain. + // Committed clear markers remain durable until the legacy profile + // projection succeeds. A restart can then reconstruct the exact + // clear intent even when the process stopped before that projection. + if (pending.kind === "clear") continue + // Other committed markers are stale bookkeeping. The child's own + // mode/apiConfigName fields retain their execution identity. try { await this.upsertCore({ ...item, pendingHandoff: undefined }, { skipTransitionCheck: true }) console.warn(`[TaskHistoryStore] Finalized pending handoff marker for committed child ${item.id}`) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts index 29caf31aeb..1aa9c1970a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts @@ -1106,6 +1106,28 @@ describe("TaskHistoryStore pendingHandoff reconciliation", () => { expect(store.get("wal-parent")?.status).toBe("active") }) + it("retains a committed clear marker until its profile projection succeeds", async () => { + const child = makeItem({ + id: "wal-clear-child", + parentTaskId: "wal-clear-parent", + status: "active", + mode: "ask", + pendingHandoff: { kind: "clear", version: 1, mode: "ask" }, + }) + const parent = makeItem({ + id: "wal-clear-parent", + status: "delegated", + awaitingChildId: "wal-clear-child", + }) + await seedItems([parent, child]) + + await store.initialize() + + expect(store.get("wal-clear-child")?.pendingHandoff).toEqual({ kind: "clear", version: 1, mode: "ask" }) + expect(store.get("wal-clear-child")?.status).toBe("interrupted") + expect(store.get("wal-clear-parent")?.status).toBe("active") + }) + it("leaves ambiguous WAL records untouched (fail-safe guards)", async () => { const emptyMode = makeItem({ id: "wal-guard-empty-mode", diff --git a/src/core/task-persistence/__tests__/providerHandoff.spec.ts b/src/core/task-persistence/__tests__/providerHandoff.spec.ts index 5d8f6a9852..b3769e9731 100644 --- a/src/core/task-persistence/__tests__/providerHandoff.spec.ts +++ b/src/core/task-persistence/__tests__/providerHandoff.spec.ts @@ -145,6 +145,8 @@ describe("provider handoff contract", () => { expect(isValidPendingHandoff({ kind: "set", version: 1, mode: "code" })).toBe(false) expect(isValidPendingHandoff({ kind: "set", version: 1, mode: "code", profileName: "" })).toBe(false) expect(isValidPendingHandoff({ kind: "set", version: 1, mode: "code", profileName: " " })).toBe(true) + expect(isValidPendingHandoff({ kind: "preserve", version: 1, mode: "code", profileName: "" })).toBe(false) + expect(isValidPendingHandoff({ kind: "preserve", version: 1, mode: "code", profileName: " " })).toBe(true) expect(isValidPendingHandoff({ kind: "unknown", version: 1, mode: "code" })).toBe(false) expect(isValidPendingHandoff({ kind: "clear", version: 1 })).toBe(false) expect(isValidPendingHandoff({ kind: "clear", version: 1, mode: "" })).toBe(false) @@ -869,7 +871,7 @@ describe("provider handoff transaction protocol", () => { }) it("accepts a background marker strip after the child started, once only", () => { - const { states } = drive(initialProviderHandoffState(), [ + const { states, rejections } = drive(initialProviderHandoffState(), [ ...happyPath.slice(0, 6), { type: "start-child" }, { type: "finalize-child-wal", ok: true }, @@ -881,6 +883,7 @@ describe("provider handoff transaction protocol", () => { expect(states[8]).toMatchObject({ phase: "child-running", childWal: "finalized" }) // Single-shot: a second strip is rejected and changes nothing. expect(states[9]).toMatchObject({ phase: "child-running", childWal: "finalized" }) + expect(rejections).toEqual(["unexpected-event"]) // A rejected late strip stays visible for restart reconciliation. const failedLate = drive(initialProviderHandoffState(), [ diff --git a/src/core/task-persistence/providerHandoff.ts b/src/core/task-persistence/providerHandoff.ts index acd64ba333..7447ee6441 100644 --- a/src/core/task-persistence/providerHandoff.ts +++ b/src/core/task-persistence/providerHandoff.ts @@ -193,7 +193,10 @@ export function isValidPendingHandoff(value: unknown): value is PendingHandoff { case "set": return typeof candidate.profileName === "string" && candidate.profileName.length > 0 case "preserve": - return candidate.profileName === undefined || typeof candidate.profileName === "string" + return ( + candidate.profileName === undefined || + (typeof candidate.profileName === "string" && candidate.profileName.length > 0) + ) case "clear": return true default: diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 188f9366aa..e549cb4a43 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -755,22 +755,25 @@ export class ClineProvider return true } // Durable reconstruction (provider reload): the in-memory sets above - // are empty after a reload, but an explicit clear durably removed the - // profile-store identity and the resumed child still carries no sticky - // profile. Reconstruct the clear from that durable state instead of - // falling back to the "default" identity. Only the still-current task - // is affected, and the read is best-effort: a failed read keeps the - // ordinary default fallback. Fresh installs carry the seeded "default" - // identity, so the legacy fallback there is unchanged. + // are empty after a reload. A committed clear marker remains on the + // child's record until the profile projection succeeds, so it remains + // authoritative if the process stopped before clearing the legacy store. + // Older records without that marker retain the profile-store fallback. const currentTask = this.getCurrentTask() if (currentTask?.taskId !== currentTaskId || currentTask.taskApiConfigName !== undefined) { return false } let durableClear = this.durableProfileClearByTaskId.get(currentTaskId) if (!durableClear) { - durableClear = this.providerSettingsManager - .getCurrentProfileName() - .then((durableIdentity) => durableIdentity === undefined) + const durableTaskRead = + this.taskHistoryStore?.readFresh(currentTaskId) ?? Promise.resolve({ kind: "missing" } as const) + durableClear = durableTaskRead + .then((result) => { + if (result.kind === "found" && result.item.pendingHandoff?.kind === "clear") return true + return this.providerSettingsManager + .getCurrentProfileName() + .then((durableIdentity) => durableIdentity === undefined) + }) .catch(() => false) this.durableProfileClearByTaskId.set(currentTaskId, durableClear) } @@ -3418,9 +3421,9 @@ export class ClineProvider terminalZdotdir: terminalZdotdir ?? false, terminalProfile, mcpEnabled: mcpEnabled ?? true, - currentApiConfigName: - currentApiConfigName ?? - ((await this.isExplicitProfileClearInForce(currentTask?.taskId)) ? undefined : "default"), + currentApiConfigName: (await this.isExplicitProfileClearInForce(currentTask?.taskId)) + ? undefined + : (currentApiConfigName ?? "default"), listApiConfigMeta: listApiConfigMeta ?? [], pinnedApiConfigs: pinnedApiConfigs ?? {}, mode: mode ?? defaultModeSlug, @@ -3688,9 +3691,9 @@ export class ClineProvider mcpServers: this.mcpHub?.getAllServers() ?? [], // Preserve an explicit no-profile handoff for the current child: // publish the absence instead of the legacy "default" fallback. - currentApiConfigName: - stateValues.currentApiConfigName ?? - ((await this.isExplicitProfileClearInForce(this.getCurrentTask()?.taskId)) ? undefined : "default"), + currentApiConfigName: (await this.isExplicitProfileClearInForce(this.getCurrentTask()?.taskId)) + ? undefined + : (stateValues.currentApiConfigName ?? "default"), listApiConfigMeta: stateValues.listApiConfigMeta ?? [], pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), @@ -5329,30 +5332,34 @@ export class ClineProvider handoffProtocol.advance({ type: "start-child" }) // 8) Best-effort finalization as handled background work, started only - // after the child is scheduled: the write-ahead marker is no longer - // needed once the delegation is durable and the child's in-memory - // context is authoritative. Store-lock contention, storage delay, + // after the child is scheduled. A clear marker remains durable until + // its profile projection succeeds. Other markers can finish now. + // Store-lock contention, storage delay, // write-through callbacks, or a strip that never settles can never // block scheduling or this method's completion. A rejected strip is // logged and the marker is left for restart reconciliation. - const finalizeCompletion = this.taskHistoryStore - .atomicReadAndUpdate(child.taskId, (historyItem) => ({ - ...historyItem, - pendingHandoff: undefined, - })) - .then(() => { - handoffProtocol.advance({ type: "finalize-child-wal", ok: true }) - }) - .catch((finalizeError) => { - handoffProtocol.advance({ type: "finalize-child-wal", ok: false }) - this.log( - `[delegateParentAndOpenChild] Pending handoff marker for child ${child.taskId} left for restart replay: ${ - (finalizeError as Error)?.message ?? String(finalizeError) - }`, - ) - }) - this.providerHandoffFinalizationCompletion = finalizeCompletion - void finalizeCompletion + const finalizeChildWal = () => + this.taskHistoryStore + .atomicReadAndUpdate(child.taskId, (historyItem) => ({ + ...historyItem, + pendingHandoff: undefined, + })) + .then(() => { + handoffProtocol.advance({ type: "finalize-child-wal", ok: true }) + }) + .catch((finalizeError) => { + handoffProtocol.advance({ type: "finalize-child-wal", ok: false }) + this.log( + `[delegateParentAndOpenChild] Pending handoff marker for child ${child.taskId} left for restart replay: ${ + (finalizeError as Error)?.message ?? String(finalizeError) + }`, + ) + }) + if (prepared.profile.intent.kind !== "clear") { + const finalizeCompletion = finalizeChildWal() + this.providerHandoffFinalizationCompletion = finalizeCompletion + void finalizeCompletion + } // 9) Best-effort legacy projection of the prepared context onto global // state and the durable profile store — fire-and-forget background @@ -5362,12 +5369,15 @@ export class ClineProvider // records the protocol landmark when still relevant. Tests await the // exposed completion hook deterministically instead of sleeping. const projectionCompletion = this.projectPreparedProviderHandoffState(prepared, child.taskId) - .then((outcome) => { + .then(async (outcome) => { handoffProtocol.advance({ type: "project-legacy", boundary: outcome.boundary ?? "context-proxy", ok: outcome.ok, }) + if (outcome.ok && prepared.profile.intent.kind === "clear") { + await finalizeChildWal() + } return outcome }) .catch(() => { @@ -5380,6 +5390,9 @@ export class ClineProvider return { ok: false, boundary: "queue" as const } }) this.providerHandoffProjectionCompletion = projectionCompletion + if (prepared.profile.intent.kind === "clear") { + this.providerHandoffFinalizationCompletion = projectionCompletion.then(() => undefined) + } void projectionCompletion // 10) Emit TaskDelegated (provider-level) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index cb72ad605d..b520db8405 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -1316,6 +1316,33 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(state.apiConfiguration).toEqual(staleMarker?.apiConfiguration) }) + test("a committed clear marker overrides a stale legacy profile identity after reload", async () => { + const { child } = await setupSoleParentDelegation() + child["_taskApiConfigName"] = undefined + provider["explicitProfileClearChildIds"].clear() + provider["durableProfileClearByTaskId"].clear() + provider["staleProviderHandoffProjection"] = undefined + await provider.contextProxy.setValue("currentApiConfigName", "stale-profile") + vi.spyOn(provider.taskHistoryStore, "readFresh").mockResolvedValue({ + kind: "found", + item: { + id: child.taskId, + number: 2, + ts: Date.now(), + task: "child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + pendingHandoff: { kind: "clear", version: 1, mode: "ask" }, + }, + }) + + const state = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + + expect(state.currentApiConfigName).toBeUndefined() + expect(provider["providerSettingsManager"].getCurrentProfileName).not.toHaveBeenCalled() + }) + test("a later successful same-child mode mutation supersedes the stale projection marker", async () => { const { child } = await setupSoleParentDelegation() From d7c74f93fc3e6e2721c70502641f5502216bb7ac Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 8 Sep 2026 04:04:09 +0000 Subject: [PATCH 17/17] fix(delegation): keep locked profile identity and settings aligned --- docs/architecture/task-lifecycle-model.md | 2 +- packages/types/src/__tests__/history.test.ts | 1 + src/core/config/ProviderSettingsManager.ts | 10 ++++ .../__tests__/ProviderSettingsManager.spec.ts | 10 ++++ src/core/webview/ClineProvider.ts | 5 +- .../ClineProvider.apiHandlerRebuild.spec.ts | 48 +++++++++++++++++-- .../ClineProvider.handoffConcurrency.spec.ts | 3 ++ 7 files changed, 72 insertions(+), 7 deletions(-) diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 7c80ca29dc..e3fcba70b5 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -66,7 +66,7 @@ Each transition holds an opaque owner token for its parent. Paths that run while Production order is prepare-before-remove. -While the parent is still the current task, handoff preparation is read-only. It runs off the provider profile mutation queue. Preparation captures the requested mode, an explicit profile projection intent (`preserve | set{name} | clear`), and a deep-cloned API configuration into one context. It performs zero writes. A hung or timed-out queued mutation can never block delegation preparation. +While the parent is still the current task, handoff preparation is read-only. It runs off the provider profile mutation queue. One locked profile-store snapshot captures the current profile and the requested mode's saved profile. The handoff decision selects both the exact profile identity and its deep-cloned API configuration from that snapshot. Preparation also captures the requested mode and explicit profile projection intent (`preserve | set{name} | clear`). It performs zero writes. A hung or timed-out queued mutation can never block delegation preparation. If preparation rejects, delegation aborts fail-closed. The parent stays current. diff --git a/packages/types/src/__tests__/history.test.ts b/packages/types/src/__tests__/history.test.ts index 3ef8678f49..c9683b780c 100644 --- a/packages/types/src/__tests__/history.test.ts +++ b/packages/types/src/__tests__/history.test.ts @@ -8,6 +8,7 @@ describe("pendingHandoffSchema", () => { expect( pendingHandoffSchema.safeParse({ kind: "preserve", version: 1, mode: "code", profileName: " " }).success, ).toBe(true) + expect(pendingHandoffSchema.safeParse({ kind: "preserve", version: 1, mode: "code" }).success).toBe(true) }) }) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 12f3648e32..9a44fe98fb 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -41,6 +41,7 @@ export interface SyncCloudProfilesResult { */ export interface ProviderProfileSnapshot { currentApiConfigName: string | undefined + currentProfile: (ProviderSettingsWithId & { name: string }) | undefined entries: ProviderSettingsEntry[] modeApiConfigId: string | undefined savedProfile: (ProviderSettingsWithId & { name: string }) | undefined @@ -550,6 +551,14 @@ export class ProviderSettingsManager { ) const modeApiConfigId = providerProfiles.modeApiConfigs?.[mode] + const currentProfile = providerProfiles.currentApiConfigName + ? providerProfiles.apiConfigs[providerProfiles.currentApiConfigName] + ? structuredClone({ + name: providerProfiles.currentApiConfigName, + ...providerProfiles.apiConfigs[providerProfiles.currentApiConfigName], + }) + : undefined + : undefined let savedProfile: (ProviderSettingsWithId & { name: string }) | undefined if (modeApiConfigId) { @@ -565,6 +574,7 @@ export class ProviderSettingsManager { return { currentApiConfigName: providerProfiles.currentApiConfigName, + currentProfile, entries, modeApiConfigId, savedProfile, diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 082725bebd..d716b569cd 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -1549,6 +1549,11 @@ describe("ProviderSettingsManager", () => { expect(mockSecrets.store).not.toHaveBeenCalled() expect(snapshot.currentApiConfigName).toBe("current-profile") + expect(snapshot.currentProfile).toMatchObject({ + name: "current-profile", + id: "current-id", + apiProvider: providerIdentifiers.openai, + }) expect(snapshot.modeApiConfigId).toBe("ask-id") expect(snapshot.entries.map(({ name, id, apiProvider }) => ({ name, id, apiProvider }))).toEqual([ { name: "current-profile", id: "current-id", apiProvider: providerIdentifiers.openai }, @@ -1588,6 +1593,11 @@ describe("ProviderSettingsManager", () => { expect(snapshot.modeApiConfigId).toBeUndefined() expect(snapshot.savedProfile).toBeUndefined() + expect(snapshot.currentProfile).toMatchObject({ + name: "current-profile", + id: "current-id", + apiProvider: providerIdentifiers.openai, + }) expect(mockSecrets.store).not.toHaveBeenCalled() }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e549cb4a43..15bf4121c3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4541,9 +4541,10 @@ export class ClineProvider savedProfile: savedProfile ? { name: savedProfile.name, id: savedProfile.id } : undefined, }) + const selectedProfile = decision.source === "saved" ? savedProfile : snapshot.currentProfile let apiConfiguration: ProviderSettings - if (savedProfile) { - const { name: _savedProfileName, id: _savedProfileId, ...profileSettings } = savedProfile + if (selectedProfile) { + const { name: _selectedProfileName, id: _selectedProfileId, ...profileSettings } = selectedProfile apiConfiguration = structuredClone(profileSettings) } else { apiConfiguration = structuredClone(this.contextProxy.getProviderSettings()) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index b520db8405..77292e86c0 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -292,6 +292,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }), snapshotForHandoff: vi.fn().mockResolvedValue({ currentApiConfigName: "test-config", + currentProfile: { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }, entries: [ { name: "test-config", @@ -1181,6 +1187,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { provider["providerSettingsManager"].snapshotForHandoff = vi.fn().mockResolvedValue({ currentApiConfigName: "test-config", + currentProfile: { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }, entries: [{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }], modeApiConfigId: "ask-id", savedProfile: { @@ -1249,6 +1261,26 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const { createTaskSpy } = await setupSoleParentDelegation() vi.mocked(mockContext.workspaceState.get).mockReturnValue(true) + provider["providerSettingsManager"].snapshotForHandoff = vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + currentProfile: { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openai, + openAiApiKey: "sk-current-profile", + }, + entries: [ + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }, + ], + modeApiConfigId: "ask-id", + savedProfile: { + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "sk-saved-profile", + }, + }) vi.mocked(mockContext.globalState.update).mockClear() await provider.delegateParentAndOpenChild({ @@ -1270,10 +1302,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { apiConfigName: "test-config", }, }) - // Locked: the child continues with the current context configuration. - expect(creationOptions.handoffExecutionContext?.apiConfiguration).toEqual( - provider.contextProxy.getProviderSettings(), - ) + // Locked: the child receives the same durable profile identity and + // configuration from one snapshot, not the requested mode's saved profile. + expect(creationOptions.handoffExecutionContext?.apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openai, + openAiApiKey: "sk-current-profile", + }) // A locked handoff carries an explicit preserve intent: no profile // write at all — and with the pin engaged there is no mode mapping @@ -1384,6 +1418,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const sentinel = "sk-handoff-sentinel-246810" provider["providerSettingsManager"].snapshotForHandoff = vi.fn().mockResolvedValue({ currentApiConfigName: "test-config", + currentProfile: { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }, entries: [{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }], modeApiConfigId: "ask-id", savedProfile: { diff --git a/src/core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts b/src/core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts index 96cc89649c..42556bbdb8 100644 --- a/src/core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.handoffConcurrency.spec.ts @@ -67,6 +67,9 @@ function snapshotForHandoff(world: SharedWorld) { } return { currentApiConfigName: store.currentApiConfigName, + currentProfile: store.currentApiConfigName + ? structuredClone({ name: store.currentApiConfigName, ...store.profiles[store.currentApiConfigName] }) + : undefined, entries: structuredClone(store.entries), modeApiConfigId, savedProfile,