Skip to content

Commit f62ed0d

Browse files
allquixoticclaude
andcommitted
fix(concurrency): audit-driven fixes for parallel-conversation invariants
22 confirmed findings from a 33-agent adversarially-verified audit of the v3.66 merge against the fork's multi-conversation contracts. Highlights: TaskHistoryStore - Process-wide shared instance per storage path (getOrCreate/release with refcounting and multi-subscriber onWrite). Separate per-provider stores each ran startup delegation reconciliation over the same files, so 'Open in New Tab' could sever a delegation that was live in the sidebar. - reconcileDelegationState re-reads parent+child fresh from disk before repairing and skips parents written within a 60s grace window (a fresh delegation whose child hasn't saved yet is indistinguishable from a crash orphan). initialize() is now idempotent. - upsert gains preserveExistingStatus: the status decision happens inside the write lock, closing the TOCTOU where a straggler saveClineMessages silently reverted 'delegated' back to 'active'. - interrupted → delegated is now a valid transition (a cancelled-then- resumed subtask could never spawn subtasks again). Delegation - delegateParentAndOpenChild is fully task-scoped: no more global handleModeSwitch (which raced concurrent delegations and polluted the visible conversation's mode/profile); the child receives taskMode and a task-scoped apiConfiguration resolved from the mode-bound profile, with the parent's config as fallback. Focus follows the parent's visibility, so a background delegation no longer steals the webview; rollback pops the child by taskId and restores the parent without grabbing focus. - activateProviderProfile only emits ProviderProfileChanged when updateCurrentTask !== false — a background restore no longer swapped the visible task's API handler and sticky profile mid-stream. - showTaskWithId redirects 'delegated' parents to their live (or resumable) child instead of live-resuming a lineage the child owns; reopenParentFromDelegation replaces a live parent instance instead of overwriting its in-memory history mid-stream. - cancelTask: rehydrate guard resolves by taskId (protects background conversations); a cancelled child that is itself delegated keeps its status; the fail-closed sever write preserves status and only poisons the reopen guard after it lands. Queued/steered messages - Queue snapshot in cancel is re-captured just before abort and restored as a 3-source merge (pre-abort snapshot, old instance's late arrivals, replacement's own queue) deduped by id — closing two loss windows. - Steer messages consumed into a turn's volatile content are tracked until persisted; a mid-stream failure retry re-enqueues them and a cancel includes them in the snapshot (previously silently dropped while showing as delivered in the transcript). - api_req_failed asks treat an auto-dispatched queued message as feedback + retry instead of misreading it as decline-to-retry and dropping it; manual Retry now sets userMessageWasRemoved so the retried request actually carries the user message. - editQueuedMessage re-enqueues the edit when the original was dequeued mid-edit; image mentions resolve against the target task's cwd and ignore rules, not the visible task's. Webview - Completion checkpoint diff/restore route by taskId (SeeNewChangesButtons now sends it) — restore no longer cancels and rewinds whichever task happens to be visible. - Answered asks clear the stale clineAsk state, so Enter during tool execution queues instead of answering an already-resolved ask; draft conversations queue by their draft id (adopted by the created task). Misc - semble downloader dedups concurrent downloads per storage dir. Tests updated to the new contracts and extended: store sharing/refcount, preserveExistingStatus TOCTOU, repair grace window, task-scoped delegation profile resolution, focus isolation, merged queue restore, orphaned- response routing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 494e8eb commit f62ed0d

18 files changed

Lines changed: 1446 additions & 340 deletions

packages/types/src/task.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from "zod"
33

44
import { RooCodeEventName } from "./events.js"
55
import type { RooCodeSettings } from "./global-settings.js"
6+
import type { ProviderSettings } from "./provider-settings.js"
67
import type { ClineMessage, QueuedMessage, TokenUsage } from "./message.js"
78
import type { ToolUsage, ToolName } from "./tool.js"
89
import type { TodoItem } from "./todo.js"
@@ -85,6 +86,18 @@ export interface CreateTaskOptions {
8586
/** Whether to start the task loop immediately (default: true).
8687
* When false, the caller must invoke `task.start()` manually. */
8788
startTask?: boolean
89+
/**
90+
* Explicit task-scoped mode. When provided, the task uses this mode instead of
91+
* asynchronously adopting the global (visible-conversation) mode — required for
92+
* delegated children so a background delegation never depends on, or races,
93+
* global state.
94+
*/
95+
taskMode?: string
96+
/**
97+
* Explicit task-scoped provider settings. When provided, overrides the global
98+
* (visible-conversation) apiConfiguration — required for delegated children.
99+
*/
100+
apiConfiguration?: ProviderSettings
88101
}
89102

90103
export enum TaskStatus {

src/__tests__/nested-delegation-resume.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,10 @@ describe("Nested delegation resume (A → B → C)", () => {
120120
const createTaskWithHistoryItem = vi
121121
.fn()
122122
.mockImplementation(async (historyItem: any, opts?: { startTask?: boolean; focus?: boolean }) => {
123-
// Assert startTask:false to avoid resume asks
124-
expect(opts).toEqual(expect.objectContaining({ startTask: false }))
123+
// Assert startTask:false to avoid resume asks; parent has no live instance on
124+
// either hop, so replaceExistingTask must be false (contract: step 7 passes
125+
// replaceExistingTask = getTaskById(parentTaskId) !== undefined)
126+
expect(opts).toEqual(expect.objectContaining({ startTask: false, replaceExistingTask: false }))
125127
// Reopen the parent
126128
currentActiveId = historyItem.id
127129
;(provider as any).clineStack = (provider as any).clineStack.filter(
@@ -410,7 +412,7 @@ describe("Nested delegation resume (A → B → C)", () => {
410412
completedByChildId: "C",
411413
awaitingChildId: undefined,
412414
}),
413-
{ startTask: false, focus: false },
415+
{ startTask: false, focus: false, replaceExistingTask: false },
414416
)
415417
})
416418
})

0 commit comments

Comments
 (0)