Skip to content

Commit b66196d

Browse files
committed
feat(TaskRegistry): introduce TaskRegistry and migrate clineStack
1 parent d5a8c4a commit b66196d

8 files changed

Lines changed: 591 additions & 82 deletions

File tree

src/__tests__/helpers/provider-stub.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { ClineProvider } from "../../core/webview/ClineProvider"
2+
import { TaskRegistry } from "../../core/task/TaskRegistry"
3+
import { type Task } from "../../core/task/Task"
24

35
/**
46
* Augments a plain stub object with the instance fields and bound methods that
57
* ClineProvider methods read from `this` (runDelegationTransition,
68
* delegationTransitionLocks, cancelledDelegationChildIds, cancellingDelegationChildIds),
79
* so tests can call private methods via `(ClineProvider.prototype as any).method.call(stub, …)`
810
* without instantiating a real ClineProvider.
11+
*
12+
* Pass `tasks` (array of Task mocks) to pre-seed the registry in stack order.
13+
* The legacy `clineStack` key is accepted and converted automatically.
914
*/
1015
export function makeProviderStub<T extends object>(stub: T): T {
1116
const s = stub as any
@@ -14,6 +19,16 @@ export function makeProviderStub<T extends object>(stub: T): T {
1419
s.cancelledDelegationChildIds ??= new Set()
1520
s.log ??= vi.fn()
1621
s.taskHistoryStore ??= { get: () => undefined }
22+
23+
// Convert legacy clineStack array into a TaskRegistry
24+
if (!s.taskRegistry) {
25+
const registry = new TaskRegistry()
26+
const seed: Task[] = s.clineStack ?? s.tasks ?? []
27+
for (const t of seed) registry.push(t)
28+
s.taskRegistry = registry
29+
}
30+
delete s.clineStack
31+
1732
s.runDelegationTransition = proto.runDelegationTransition.bind(s)
1833
s.removeClineFromStack ??= proto.removeClineFromStack.bind(s)
1934
s.evictCurrentTask ??= proto.evictCurrentTask.bind(s)

src/__tests__/removeClineFromStack-delegation.spec.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it, expect, vi } from "vitest"
44
import { ClineProvider } from "../core/webview/ClineProvider"
55
import { makeProviderStub } from "./helpers/provider-stub"
66

7-
// After the refactor: removeClineFromStack() is pure lifecycle — it pops, aborts, and
7+
// After the refactor: removeClineFromStack() is pure lifecycle — it removes the focused task, aborts, and
88
// cleans up listeners. It does NOT mutate delegation metadata. All delegated→active
99
// transitions are owned by reopenParentFromDelegation() (normal child completion) or
1010
// markDelegatedChildInterrupted() (live eviction via navigation / new-task / clear).
@@ -49,17 +49,47 @@ function buildMockProvider(opts: {
4949
}
5050

5151
describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation side effects", () => {
52-
it("pops the task, aborts it, and clears listeners", async () => {
52+
it("removes the focused task, aborts it, and clears listeners", async () => {
5353
const { provider, childTask } = buildMockProvider({ childTaskId: "child-1" })
54-
expect(provider.clineStack).toHaveLength(1)
54+
expect((provider as any).taskRegistry.length).toBe(1)
5555

5656
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
5757

58-
expect(provider.clineStack).toHaveLength(0)
58+
expect((provider as any).taskRegistry.length).toBe(0)
5959
expect(childTask.abortTask).toHaveBeenCalledWith(true)
6060
expect(childTask.emit).toHaveBeenCalledWith(expect.stringContaining("taskUnfocused"))
6161
})
6262

63+
it("removes the focused task even when it is not the top stack entry", async () => {
64+
const focusedTask = {
65+
taskId: "focused-1",
66+
instanceId: "focused-inst",
67+
emit: vi.fn(),
68+
abortTask: vi.fn().mockResolvedValue(undefined),
69+
}
70+
const topTask = {
71+
taskId: "top-1",
72+
instanceId: "top-inst",
73+
emit: vi.fn(),
74+
abortTask: vi.fn().mockResolvedValue(undefined),
75+
}
76+
const provider = makeProviderStub({
77+
tasks: [focusedTask, topTask] as any[],
78+
taskEventListeners: new Map(),
79+
log: vi.fn(),
80+
getTaskWithId: vi.fn(),
81+
updateTaskHistory: vi.fn(),
82+
})
83+
;(provider as any).taskRegistry.setCurrent("focused-1")
84+
85+
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
86+
87+
expect((provider as any).taskRegistry.taskIds).toEqual(["top-1"])
88+
expect((provider as any).taskRegistry.current).toBe(topTask)
89+
expect(focusedTask.abortTask).toHaveBeenCalledWith(true)
90+
expect(topTask.abortTask).not.toHaveBeenCalled()
91+
})
92+
6393
it("does NOT mutate parent metadata when a delegated child is popped (repair removed)", async () => {
6494
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
6595
childTaskId: "child-1",
@@ -74,7 +104,7 @@ describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation
74104

75105
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
76106

77-
expect(provider.clineStack).toHaveLength(0)
107+
expect((provider as any).taskRegistry.length).toBe(0)
78108
// Navigation/disposal must never silently flip the parent to active
79109
expect(getTaskWithId).not.toHaveBeenCalled()
80110
expect(updateTaskHistory).not.toHaveBeenCalled()
@@ -94,7 +124,7 @@ describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation
94124

95125
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
96126

97-
expect(provider.clineStack).toHaveLength(0)
127+
expect((provider as any).taskRegistry.length).toBe(0)
98128
expect(getTaskWithId).not.toHaveBeenCalled()
99129
expect(updateTaskHistory).not.toHaveBeenCalled()
100130
})
@@ -106,7 +136,7 @@ describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation
106136

107137
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
108138

109-
expect(provider.clineStack).toHaveLength(0)
139+
expect((provider as any).taskRegistry.length).toBe(0)
110140
expect(getTaskWithId).not.toHaveBeenCalled()
111141
expect(updateTaskHistory).not.toHaveBeenCalled()
112142
})
@@ -490,7 +520,7 @@ describe("ClineProvider.evictCurrentTask() — active delegated child path", ()
490520

491521
await (ClineProvider.prototype as any).evictCurrentTask.call(provider)
492522

493-
expect(provider.clineStack).toHaveLength(0)
523+
expect((provider as any).taskRegistry.length).toBe(0)
494524
expect(markDelegatedChildInterrupted).toHaveBeenCalledWith({ childTaskId, parentTaskId })
495525
})
496526

src/__tests__/single-open-invariant.spec.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, vi, beforeEach } from "vitest"
44
import { ClineProvider } from "../core/webview/ClineProvider"
5+
import { TaskRegistry } from "../core/task/TaskRegistry"
56
import { API } from "../extension/api"
67
import * as ProfileValidatorMod from "../shared/ProfileValidator"
78

@@ -39,10 +40,11 @@ describe("Single-open-task invariant", () => {
3940
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
4041
const addClineToStack = vi.fn().mockResolvedValue(undefined)
4142

42-
const existingTask = { taskId: "existing-1" }
43+
const existingTask = { taskId: "existing-1", abort: false, abandoned: false }
44+
const registry = new TaskRegistry()
45+
registry.push(existingTask as any)
4346
const provider = {
44-
// Simulate an existing task present in stack
45-
clineStack: [existingTask],
47+
taskRegistry: registry,
4648
getCurrentTask: vi.fn(() => existingTask),
4749
taskHistoryStore: { get: vi.fn(() => undefined) },
4850
markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined),
@@ -85,10 +87,12 @@ describe("Single-open-task invariant", () => {
8587

8688
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
8789
const addClineToStack = vi.fn().mockResolvedValue(undefined)
88-
const parentTask = { taskId: "parent-1" }
90+
const parentTask = { taskId: "parent-1", abort: false, abandoned: false }
91+
const registry2 = new TaskRegistry()
92+
registry2.push(parentTask as any)
8993

9094
const provider = {
91-
clineStack: [parentTask],
95+
taskRegistry: registry2,
9296
setValues: vi.fn(),
9397
getState: vi.fn().mockResolvedValue({
9498
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },

src/core/task/TaskRegistry.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { type Task } from "./Task"
2+
3+
/**
4+
* Expand-Contract adapter replacing `clineStack: Task[]` in ClineProvider.
5+
*
6+
* Phase A+B: adapter is live, all 23 call sites migrated, concurrent access
7+
* methods available. Maintains a LIFO stack of task IDs alongside a Map for
8+
* O(1) lookup. Invariant: tasks.size === stack.length at all times.
9+
*
10+
* Phase C (future): remove internal stack once all callers use map-based access.
11+
*/
12+
export class TaskRegistry {
13+
private tasks = new Map<string, Task>()
14+
private stack: string[] = []
15+
private _currentTaskId: string | undefined
16+
17+
push(task: Task): void {
18+
if (this.tasks.has(task.taskId)) {
19+
this.remove(task.taskId)
20+
}
21+
this.tasks.set(task.taskId, task)
22+
this.stack.push(task.taskId)
23+
this._currentTaskId = task.taskId
24+
}
25+
26+
pop(): Task | undefined {
27+
const id = this.stack.pop()
28+
if (id === undefined) return undefined
29+
const task = this.tasks.get(id)
30+
this.tasks.delete(id)
31+
if (this._currentTaskId === id) {
32+
this._currentTaskId = this.stack[this.stack.length - 1]
33+
}
34+
return task
35+
}
36+
37+
get current(): Task | undefined {
38+
return this._currentTaskId !== undefined ? this.tasks.get(this._currentTaskId) : undefined
39+
}
40+
41+
/**
42+
* Switch UI focus to a different task without mutating the stack order.
43+
* Throws if the taskId is not in the registry.
44+
*/
45+
setCurrent(taskId: string): void {
46+
if (!this.tasks.has(taskId)) {
47+
throw new Error(`[TaskRegistry] setCurrent: unknown taskId ${taskId}`)
48+
}
49+
this._currentTaskId = taskId
50+
}
51+
52+
getById(id: string): Task | undefined {
53+
return this.tasks.get(id)
54+
}
55+
56+
getAll(): Task[] {
57+
return this.stack.map((id) => this.tasks.get(id)!)
58+
}
59+
60+
/** All tasks that are not aborted or abandoned. */
61+
getRunning(): Task[] {
62+
return this.getAll().filter((t) => !t.abort && !t.abandoned)
63+
}
64+
65+
/** True only if the task is in the registry and is not aborted or abandoned. */
66+
hasRunning(taskId: string): boolean {
67+
const t = this.tasks.get(taskId)
68+
return t !== undefined && !t.abort && !t.abandoned
69+
}
70+
71+
/** Remove a task by ID regardless of its stack position. */
72+
remove(taskId: string): Task | undefined {
73+
const task = this.tasks.get(taskId)
74+
if (task === undefined) return undefined
75+
this.tasks.delete(taskId)
76+
const idx = this.stack.indexOf(taskId)
77+
if (idx !== -1) this.stack.splice(idx, 1)
78+
if (this._currentTaskId === taskId) {
79+
this._currentTaskId = this.stack[this.stack.length - 1]
80+
}
81+
return task
82+
}
83+
84+
/**
85+
* Replace a task in-place: same stack index, same current pointer.
86+
* Used by rehydration so focus and stack order are both preserved.
87+
* Throws if taskId is not in the registry.
88+
*/
89+
replace(taskId: string, replacement: Task): Task {
90+
const idx = this.stack.indexOf(taskId)
91+
if (idx === -1) {
92+
throw new Error(`[TaskRegistry] replace: unknown taskId ${taskId}`)
93+
}
94+
if (replacement.taskId !== taskId && this.tasks.has(replacement.taskId)) {
95+
throw new Error(`[TaskRegistry] replace: duplicate taskId ${replacement.taskId}`)
96+
}
97+
const old = this.tasks.get(taskId)!
98+
this.tasks.delete(taskId)
99+
this.tasks.set(replacement.taskId, replacement)
100+
this.stack[idx] = replacement.taskId
101+
if (this._currentTaskId === taskId) {
102+
this._currentTaskId = replacement.taskId
103+
}
104+
return old
105+
}
106+
107+
get length(): number {
108+
return this.stack.length
109+
}
110+
111+
get taskIds(): string[] {
112+
return [...this.stack]
113+
}
114+
}

0 commit comments

Comments
 (0)