Skip to content
Merged
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ This file provides guidance to agents when working with code in this repository.
- Settings View Pattern: When working on `SettingsView`, inputs must bind to the local `cachedState`, NOT the live `useExtensionState()`. The `cachedState` acts as a buffer for user edits, isolating them from the `ContextProxy` source-of-truth until the user explicitly clicks "Save". Wiring inputs directly to the live state causes race conditions.
- Changesets: Do NOT create `.changeset` files for each commit or code change. Changesets are managed separately by maintainers and should not be generated by agents during normal development.

## ESLint Suppressions

`src/eslint-suppressions.json` tracks per-file counts of suppressed lint rules. Suppression counts must never increase. When touching a file, prefer reducing its count when the fix is local and low-risk; avoid broad unrelated cleanup.

When writing new code:

- Fix lint violations in the new code rather than suppressing them.
- Avoid `as any`; use typed APIs directly (e.g. `RooCodeEventName.X` constants with typed `on()`/`listenerCount()`), or bracket notation (`obj["privateField"]`) to access private members. Prefer precise test doubles or `unknown` with a type guard over double assertions (`as unknown as T`); use double assertions only as a last resort, with a comment explaining why.
- Avoid floating promises; add `void`, `await`, or `.catch()` as appropriate.
- After editing a file, run `pnpm --dir src exec eslint --prune-suppressions --max-warnings=0 <relative-file>` and confirm the count for that file did not increase.
- If a suppression is truly unavoidable (e.g. `vi.spyOn(Cls.prototype as any, "privateMethod")` where no typed alternative exists), document why in a comment next to the cast.

## Test Placement Guidance

Prefer the narrowest test layer that proves the behavior. This follows standard test-pyramid guidance: keep most coverage in fast, focused tests; add integration tests for cross-module contracts; reserve end-to-end tests for full workflow confidence.
Expand Down
46 changes: 28 additions & 18 deletions src/__tests__/provider-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from "vitest"
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"

const parentHistoryItem: HistoryItem = {
id: "parent-1",
Expand Down Expand Up @@ -46,13 +47,14 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
const providerEmit = vi.fn()
const parentTask = makeParentTask()

const childStart = vi.fn()
const childRun = vi.fn().mockResolvedValue(undefined)
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart })
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: childRun })
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
const taskHistoryStore = makeStoreStub()

const provider = {
taskScheduler: new TaskScheduler(),
emit: providerEmit,
getCurrentTask: vi.fn(() => parentTask),
removeClineFromStack,
Expand All @@ -70,6 +72,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
initialTodos: [],
mode: "code",
})
await Promise.resolve() // drain scheduler microtask so child.run() is invoked

expect(child.taskId).toBe("child-1")

Expand Down Expand Up @@ -98,8 +101,8 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
childIds: expect.arrayContaining(["child-1"]),
})

// child.start() called AFTER parent metadata is persisted
expect(childStart).toHaveBeenCalledTimes(1)
// child.run() called AFTER parent metadata is persisted (via taskScheduler)
expect(childRun).toHaveBeenCalledTimes(1)

// Provider-level event
expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1")
Expand All @@ -117,10 +120,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
})

const provider = {
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() }),
createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: () => Promise.resolve() }),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
postMessageToWebview,
log: vi.fn(),
Expand Down Expand Up @@ -150,10 +154,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
})

const provider = {
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() }),
createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: () => Promise.resolve() }),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
postMessageToWebview,
log: vi.fn(),
Expand All @@ -172,15 +177,15 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
expect(postMessageToWebview).not.toHaveBeenCalled()
})

it("calls child.start() only after atomicReadAndUpdate completes (no race condition)", async () => {
it("calls child.run() only after atomicReadAndUpdate completes (no race condition)", async () => {
const callOrder: string[] = []

const parentTask = makeParentTask()
const childStart = vi.fn(() => callOrder.push("child.start"))
const childRun = vi.fn(async () => callOrder.push("child.run"))
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTask = vi.fn(async () => {
callOrder.push("createTask")
return { taskId: "child-1", start: childStart }
return { taskId: "child-1", start: vi.fn(), run: childRun }
})
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
const taskHistoryStore = makeStoreStub({
Expand All @@ -191,6 +196,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
})

const provider = {
taskScheduler: new TaskScheduler(),
emit: vi.fn(),
getCurrentTask: vi.fn(() => parentTask),
removeClineFromStack,
Expand All @@ -208,9 +214,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
initialTodos: [],
mode: "code",
})
await Promise.resolve() // drain scheduler microtask so child.run() is invoked

// createTask → atomicReadAndUpdate → child.start: lock must release before start
expect(callOrder).toEqual(["createTask", "atomicReadAndUpdate", "child.start"])
// createTask → atomicReadAndUpdate → child.run: scheduler admits child only after metadata is persisted
expect(callOrder).toEqual(["createTask", "atomicReadAndUpdate", "child.run"])
})

it("implicitly severs interrupted awaited child and re-delegates when parent is already delegated", async () => {
Expand All @@ -236,10 +243,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
})

const provider = {
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() }),
createTask: vi.fn().mockResolvedValue({ taskId: "child-2", start: vi.fn(), run: () => Promise.resolve() }),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
isViewLaunched: false,
Expand Down Expand Up @@ -277,7 +285,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
delegatedToId: oldChildId,
} as unknown as HistoryItem

const child = { taskId: "child-2", start: vi.fn() }
const child = { taskId: "child-2", start: vi.fn(), run: vi.fn().mockResolvedValue(undefined) }
const getCurrentTask = vi.fn().mockReturnValue(makeParentTask())
const createTask = vi.fn().mockImplementation(async () => {
getCurrentTask.mockReturnValue(child)
Expand All @@ -296,6 +304,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
})

const provider = {
taskScheduler: new TaskScheduler(),
emit: vi.fn(),
getCurrentTask,
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
Expand All @@ -319,15 +328,15 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
}),
).rejects.toThrow("Cannot re-delegate")

// Rollback: child must not have started, and must be cleaned up
expect(child.start).not.toHaveBeenCalled()
// Rollback: child must not have run, and must be cleaned up
expect(child.run).not.toHaveBeenCalled()
expect((provider as any).deleteTaskWithId).toHaveBeenCalledWith("child-2", false)
})

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 childStart = vi.fn()
const childRun = vi.fn().mockResolvedValue(undefined)
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const deleteTaskWithId = vi.fn().mockResolvedValue(undefined)
const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined)
Expand All @@ -337,7 +346,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
atomicReadAndUpdate: vi.fn().mockRejectedValue(persistError),
})

const child = { taskId: "child-1", start: childStart }
const child = { taskId: "child-1", start: vi.fn(), run: childRun }
// Before createTask: getCurrentTask returns parent (used by step 3 close).
// After createTask: returns child so the rollback guard passes and the child is popped.
const getCurrentTask = vi.fn().mockReturnValue(parentTask)
Expand All @@ -347,6 +356,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
})

const provider = {
taskScheduler: new TaskScheduler(),
emit: vi.fn(),
getCurrentTask,
removeClineFromStack,
Expand All @@ -370,7 +380,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
}),
).rejects.toThrow(persistError)

expect(childStart).not.toHaveBeenCalled()
expect(childRun).not.toHaveBeenCalled()
expect(removeClineFromStack).toHaveBeenNthCalledWith(1)
expect(removeClineFromStack).toHaveBeenNthCalledWith(2)
expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false)
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/single-open-invariant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"
import { type OutputChannel } from "vscode"
import { ClineProvider } from "../core/webview/ClineProvider"
import { TaskRegistry } from "../core/task/TaskRegistry"
import { TaskScheduler } from "../core/task/TaskScheduler"
import { type Task } from "../core/task/Task"
import { API } from "../extension/api"
import * as ProfileValidatorMod from "../shared/ProfileValidator"
Expand Down Expand Up @@ -45,6 +46,9 @@ vi.mock("../core/task/Task", () => {
opts.onCreated?.(this)
}
start() {}
run() {
return Promise.resolve()
}
on() {}
off() {}
emit() {}
Expand All @@ -69,6 +73,7 @@ describe("Single-open-task invariant", () => {
registry.push(existingTask as unknown as Task)
const provider = {
taskRegistry: registry,
taskScheduler: new TaskScheduler(),
getCurrentTask: vi.fn(() => existingTask),
taskHistoryStore: { get: vi.fn(() => undefined) },
markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -117,6 +122,7 @@ describe("Single-open-task invariant", () => {

const provider = {
taskRegistry: registry2,
taskScheduler: new TaskScheduler(),
setValues: vi.fn(),
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },
Expand Down
22 changes: 22 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
didToolFailInCurrentTurn = false
didCompleteReadingStream = false
private _started = false
private _runPromise: Promise<void> | undefined
// No streaming parser is required.
assistantMessageParser?: undefined
private providerProfileChangeListener?: (config: { name: string; provider?: string }) => void
Expand Down Expand Up @@ -1875,6 +1876,27 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

/**
* Like `start()`, but returns the underlying promise so callers (e.g.
* `TaskScheduler`) can await task completion and gate concurrency.
* Idempotent: subsequent calls return the same in-flight promise.
*/
public run(): Promise<void> {
if (this._runPromise !== undefined) {
return this._runPromise
}
if (this._started) {
// Already launched via constructor or start() — no promise to return.
return Promise.resolve()
}
this._started = true

const { task, images } = this.metadata

this._runPromise = task || images ? this.startTask(task ?? undefined, images ?? undefined) : Promise.resolve()
return this._runPromise
}

private async startTask(task?: string, images?: string[]): Promise<void> {
try {
// `conversationHistory` (for API) and `clineMessages` (for webview)
Expand Down
52 changes: 52 additions & 0 deletions src/core/task/TaskScheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { TaskSemaphore } from "../../utils/TaskSemaphore"
import { type Task } from "./Task"

/**
* Semaphore-based concurrency gate for task execution.
*
* Ships at maxConcurrency=1, which is structurally identical to the current
* serial behavior. Raising maxConcurrency later enables Story 3.2b fan-out
* without touching the gate logic here.
*/
export class TaskScheduler {
private readonly sem: TaskSemaphore

constructor(maxConcurrency = 1) {
this.sem = new TaskSemaphore(maxConcurrency)
}

get waiting(): number {
return this.sem.waiting
}

/**
* Acquire a permit for `task`, call `run()`, and release on completion.
*
* The returned promise resolves/rejects with the same value as `run()`.
* Release is guaranteed via try/finally even if `run()` throws.
*
* If the task was aborted or abandoned while waiting for a permit (e.g. the
* user cancelled it before it started), the permit is released immediately
* without calling `run()`.
*/
async schedule(task: Task, run: () => Promise<void>): Promise<void> {
const release = await this.sem.acquire()
if (task.abort || task.abandoned) {
release()
return
}
try {
await run()
} finally {
release()
}
}

/**
* Cancel all queued (waiting) tasks. Tasks that already acquired a permit
* are not affected — they continue to run to completion.
*/
cancelQueued(): void {
this.sem.cancel()
}
}
Loading
Loading