@hacky.software/opencode-todo-supervisor 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +366 -2
- package/index.ts +3 -0
- package/package.json +61 -4
- package/src/index.ts +186 -0
- package/src/log.ts +77 -0
- package/src/machine.test.ts +779 -0
- package/src/markdown.test.ts +135 -0
- package/src/markdown.ts +159 -0
- package/src/options.test.ts +42 -0
- package/src/options.ts +100 -0
- package/src/panel.test.ts +193 -0
- package/src/panel.ts +289 -0
- package/src/rpc.test.ts +226 -0
- package/src/rpc.ts +290 -0
- package/src/storage.test.ts +101 -0
- package/src/storage.ts +186 -0
- package/src/supervisor.test.ts +37 -0
- package/src/supervisor.ts +1269 -0
- package/src/tools.test.ts +260 -0
- package/src/tools.ts +405 -0
- package/src/tui.tsx +658 -0
- package/tui.ts +2 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// todo-supervisor — state-machine tests (PLAN.md §5.4, aspects 6–9).
|
|
2
|
+
//
|
|
3
|
+
// Real timers with millisecond-scale options keep the harness honest without
|
|
4
|
+
// mocking: `settleMs: 20`, `minNudgeIntervalMs: 120`, `stallMinutes: 0.005`
|
|
5
|
+
// (= 300 ms), `maxNoProgressNudges: 3`.
|
|
6
|
+
import { describe, expect, test } from "bun:test"
|
|
7
|
+
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"
|
|
8
|
+
import type { MarkdownCache, MarkdownItem, MarkdownState, ParsedFile } from "./markdown"
|
|
9
|
+
import { parseOptions } from "./options"
|
|
10
|
+
import type { TodoItem, TodoStore } from "./storage"
|
|
11
|
+
import { Supervisor, type ClaimsStore, type SessionRef, type SupervisorDeps } from "./supervisor"
|
|
12
|
+
|
|
13
|
+
type SyntheticCall = {
|
|
14
|
+
sessionID: string
|
|
15
|
+
text: string
|
|
16
|
+
description?: string
|
|
17
|
+
metadata?: Record<string, unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Shared = {
|
|
21
|
+
todos: Map<string, TodoItem[]>
|
|
22
|
+
sessions: Map<string, SessionRef>
|
|
23
|
+
claims: Map<string, unknown>
|
|
24
|
+
/** When set, `refreshTodos` throws — storage-failure injection (§AC aspect 13). */
|
|
25
|
+
failRefresh?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const shared = (): Shared => ({ todos: new Map(), sessions: new Map(), claims: new Map() })
|
|
29
|
+
|
|
30
|
+
function makeHarness(state: Shared = shared(), overrides: Record<string, unknown> = {}, tag = "test#1@aaa") {
|
|
31
|
+
const calls: SyntheticCall[] = []
|
|
32
|
+
const logs: string[] = []
|
|
33
|
+
const failures = { remaining: 0 }
|
|
34
|
+
const cache = new Map<string, TodoItem[]>()
|
|
35
|
+
const subs = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{ version: 1; sessionID: string; files: string[]; removed: string[]; updatedAt: number }
|
|
38
|
+
>()
|
|
39
|
+
const mdFiles = new Map<string, ParsedFile>()
|
|
40
|
+
const rpcEvents: { name: string; payload: Record<string, unknown> }[] = []
|
|
41
|
+
const options = parseOptions({
|
|
42
|
+
settleMs: 20,
|
|
43
|
+
minNudgeIntervalMs: 120,
|
|
44
|
+
stallMinutes: 0.005,
|
|
45
|
+
maxNoProgressNudges: 3,
|
|
46
|
+
...overrides,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const claims: ClaimsStore = {
|
|
50
|
+
get: async (key) => state.claims.get(key),
|
|
51
|
+
set: async (key, value) => void state.claims.set(key, value),
|
|
52
|
+
remove: async (key) => void state.claims.delete(key),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// getTodos is cache-only like the real store; refreshTodos re-reads the shared
|
|
56
|
+
// "storage" (state.todos), so tests can simulate writes from another role.
|
|
57
|
+
const store = {
|
|
58
|
+
getTodos: (sessionID: string) =>
|
|
59
|
+
cache.has(sessionID)
|
|
60
|
+
? { version: 1, sessionID, items: cache.get(sessionID)!, updatedAt: 0 }
|
|
61
|
+
: undefined,
|
|
62
|
+
refreshTodos: async (sessionID: string) => {
|
|
63
|
+
if (state.failRefresh) throw new Error("storage offline")
|
|
64
|
+
const items = state.todos.get(sessionID)
|
|
65
|
+
if (items) {
|
|
66
|
+
cache.set(sessionID, items)
|
|
67
|
+
return { version: 1, sessionID, items, updatedAt: 0 }
|
|
68
|
+
}
|
|
69
|
+
cache.delete(sessionID)
|
|
70
|
+
return undefined
|
|
71
|
+
},
|
|
72
|
+
scanAll: async () => {
|
|
73
|
+
const out = [...state.todos.entries()].map(([sessionID, items]) => {
|
|
74
|
+
cache.set(sessionID, items)
|
|
75
|
+
return { version: 1, sessionID, items, updatedAt: 0 }
|
|
76
|
+
})
|
|
77
|
+
return out
|
|
78
|
+
},
|
|
79
|
+
removeTodos: async (sessionID: string) => {
|
|
80
|
+
cache.delete(sessionID)
|
|
81
|
+
state.todos.delete(sessionID)
|
|
82
|
+
},
|
|
83
|
+
removeSubs: async (sessionID: string) => void subs.delete(sessionID),
|
|
84
|
+
loadSubs: async (sessionID: string) => subs.get(sessionID),
|
|
85
|
+
setSubs: async (sessionID: string, files: string[], removed: string[]) => {
|
|
86
|
+
const subsState = { version: 1 as const, sessionID, files, removed, updatedAt: 0 }
|
|
87
|
+
subs.set(sessionID, subsState)
|
|
88
|
+
return subsState
|
|
89
|
+
},
|
|
90
|
+
} as unknown as TodoStore
|
|
91
|
+
|
|
92
|
+
const deps: SupervisorDeps = {
|
|
93
|
+
options,
|
|
94
|
+
store,
|
|
95
|
+
projectID: "proj-1",
|
|
96
|
+
tag,
|
|
97
|
+
log: (...args) => void logs.push(args.map(String).join(" ")),
|
|
98
|
+
claims,
|
|
99
|
+
retry: { delayMs: 30, attempts: 3 },
|
|
100
|
+
emitRpcEvent: (name, payload) => void rpcEvents.push({ name, payload }),
|
|
101
|
+
synthetic: async (input) => {
|
|
102
|
+
if (failures.remaining > 0) {
|
|
103
|
+
failures.remaining -= 1
|
|
104
|
+
throw new Error("session busy (409)")
|
|
105
|
+
}
|
|
106
|
+
calls.push(input)
|
|
107
|
+
},
|
|
108
|
+
getSession: async (sessionID) => state.sessions.get(sessionID),
|
|
109
|
+
markdown: {
|
|
110
|
+
parse: (path: string) => mdFiles.get(path) ?? { path, exists: false, items: [] },
|
|
111
|
+
} as unknown as MarkdownCache,
|
|
112
|
+
directory: "/proj",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { sup: new Supervisor(deps), calls, logs, failures, state, options, cache, rpcEvents, subs, mdFiles }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type H = ReturnType<typeof makeHarness>
|
|
119
|
+
|
|
120
|
+
const item = (content: string, status: TodoItem["status"] = "pending"): TodoItem => ({ content, status })
|
|
121
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
122
|
+
const dispatch = (h: H, type: string, data: Record<string, unknown>) => h.sup.handle({ type, data })
|
|
123
|
+
const idle = (h: H, sessionID = "s1") =>
|
|
124
|
+
dispatch(h, "session.status", { sessionID, status: { type: "idle" } })
|
|
125
|
+
|
|
126
|
+
const shellCreated = (h: H, sessionID: string, shellID: string, command: string, file?: string) =>
|
|
127
|
+
dispatch(h, "shell.created", {
|
|
128
|
+
info: {
|
|
129
|
+
id: shellID,
|
|
130
|
+
status: "running",
|
|
131
|
+
command,
|
|
132
|
+
...(file ? { file } : {}),
|
|
133
|
+
metadata: { sessionID },
|
|
134
|
+
time: { started: Date.now() },
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
function seed(h: H, sessionID: string, items: TodoItem[], ref: SessionRef = { projectID: "proj-1" }): void {
|
|
139
|
+
h.state.todos.set(sessionID, items)
|
|
140
|
+
h.cache.set(sessionID, items)
|
|
141
|
+
h.state.sessions.set(sessionID, ref)
|
|
142
|
+
h.sup.registerSession(sessionID, ref)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Register a session without a session todo list (markdown-only supervision). */
|
|
146
|
+
function seedSession(h: H, sessionID: string, ref: SessionRef = { projectID: "proj-1" }): void {
|
|
147
|
+
h.state.sessions.set(sessionID, ref)
|
|
148
|
+
h.sup.registerSession(sessionID, ref)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function mdFile(path: string, rows: Array<[number, string, MarkdownState]>, exists = true): ParsedFile {
|
|
152
|
+
return {
|
|
153
|
+
path,
|
|
154
|
+
exists,
|
|
155
|
+
items: rows.map(([line, text, state]): MarkdownItem => ({ path, line, text, indent: "", state })),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function subscribe(h: H, sessionID: string, files: string[], removed: string[] = []): void {
|
|
160
|
+
h.subs.set(sessionID, { version: 1, sessionID, files, removed, updatedAt: 0 })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
describe("idle evaluation & nudging (aspect 7)", () => {
|
|
164
|
+
test("idle session with open todos gets one nudge after settleMs with status + explainer", async () => {
|
|
165
|
+
const h = makeHarness()
|
|
166
|
+
seed(h, "s1", [item("one"), item("two", "in_progress"), item("three", "completed")])
|
|
167
|
+
await idle(h)
|
|
168
|
+
expect(h.calls.length).toBe(0)
|
|
169
|
+
await sleep(80)
|
|
170
|
+
expect(h.calls.length).toBe(1)
|
|
171
|
+
const call = h.calls[0]!
|
|
172
|
+
expect(call.sessionID).toBe("s1")
|
|
173
|
+
expect(call.metadata?.["source"]).toBe("todo-supervisor")
|
|
174
|
+
expect(call.text).toContain("Idle nudge #1")
|
|
175
|
+
expect(call.text).toContain("2 of 3 todos left, 1 in progress")
|
|
176
|
+
expect(call.text).toContain("- [ ] one")
|
|
177
|
+
expect(call.text).toContain("- [~] two")
|
|
178
|
+
expect(call.text).not.toContain("three")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test("priority and resume hint are visible in the nudge text", async () => {
|
|
182
|
+
const h = makeHarness()
|
|
183
|
+
seed(h, "s1", [
|
|
184
|
+
{ content: "important", status: "pending", priority: "high" },
|
|
185
|
+
{ content: "later", status: "pending", priority: "low" },
|
|
186
|
+
])
|
|
187
|
+
await idle(h)
|
|
188
|
+
await sleep(80)
|
|
189
|
+
expect(h.calls.length).toBe(1)
|
|
190
|
+
const text = h.calls[0]!.text
|
|
191
|
+
expect(text).toContain("- [ ] important (high)")
|
|
192
|
+
expect(text).toContain("- [ ] later (low)")
|
|
193
|
+
expect(text).toContain("start with the [~] item when present")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("nudges re-read storage so writes from another role are visible", async () => {
|
|
197
|
+
const h = makeHarness()
|
|
198
|
+
seed(h, "s1", [item("old task")])
|
|
199
|
+
// Another role's tool call writes shared storage, not this instance's cache:
|
|
200
|
+
h.state.todos.set("s1", [item("new task"), item("old task", "completed")])
|
|
201
|
+
await idle(h)
|
|
202
|
+
await sleep(80)
|
|
203
|
+
expect(h.calls.length).toBe(1)
|
|
204
|
+
const text = h.calls[0]!.text
|
|
205
|
+
expect(text).toContain("- [ ] new task")
|
|
206
|
+
expect(text).toContain("1 of 2 todos left")
|
|
207
|
+
expect(text).not.toContain("- [ ] old task")
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("nudged RPC event fires exactly once per successful nudge", async () => {
|
|
211
|
+
const h = makeHarness()
|
|
212
|
+
seed(h, "s1", [item("one")])
|
|
213
|
+
await idle(h)
|
|
214
|
+
await sleep(80)
|
|
215
|
+
expect(h.calls.length).toBe(1)
|
|
216
|
+
const nudged = h.rpcEvents.filter((e) => e.name === "nudged")
|
|
217
|
+
expect(nudged.length).toBe(1)
|
|
218
|
+
expect(nudged[0]!.payload.sessionID).toBe("s1")
|
|
219
|
+
expect(nudged[0]!.payload.reason).toBe("open-todos")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("snapshot reports registry state for RPC list", () => {
|
|
223
|
+
const h = makeHarness()
|
|
224
|
+
seed(h, "s1", [item("one")])
|
|
225
|
+
const snap = h.sup.snapshot("s1")
|
|
226
|
+
expect(snap.registered).toBe(true)
|
|
227
|
+
expect(snap.paused).toBe(false)
|
|
228
|
+
expect(snap.status).toBe("unknown")
|
|
229
|
+
expect(h.sup.snapshot("ses_missing")).toEqual({ registered: false })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test("no nudge for empty or fully done lists", async () => {
|
|
233
|
+
const h = makeHarness()
|
|
234
|
+
seed(h, "empty", [])
|
|
235
|
+
seed(h, "done", [item("a", "completed"), item("b", "canceled")])
|
|
236
|
+
await idle(h, "empty")
|
|
237
|
+
await idle(h, "done")
|
|
238
|
+
await sleep(80)
|
|
239
|
+
expect(h.calls.length).toBe(0)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test("consecutive nudges are at least minNudgeIntervalMs apart", async () => {
|
|
243
|
+
const h = makeHarness()
|
|
244
|
+
seed(h, "s1", [item("one")])
|
|
245
|
+
await idle(h)
|
|
246
|
+
await sleep(80)
|
|
247
|
+
expect(h.calls.length).toBe(1)
|
|
248
|
+
await idle(h)
|
|
249
|
+
await sleep(80) // settle re-fires, but we are inside the interval
|
|
250
|
+
expect(h.calls.length).toBe(1)
|
|
251
|
+
await sleep(700) // interval + reschedule epsilon
|
|
252
|
+
expect(h.calls.length).toBe(2)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test("running session cancels the pending evaluation", async () => {
|
|
256
|
+
const h = makeHarness()
|
|
257
|
+
seed(h, "s1", [item("one")])
|
|
258
|
+
await idle(h)
|
|
259
|
+
await sleep(5)
|
|
260
|
+
await dispatch(h, "session.execution.started", { sessionID: "s1" })
|
|
261
|
+
await sleep(100)
|
|
262
|
+
expect(h.calls.length).toBe(0)
|
|
263
|
+
await dispatch(h, "session.execution.succeeded", { sessionID: "s1" })
|
|
264
|
+
await sleep(80)
|
|
265
|
+
expect(h.calls.length).toBe(1)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test("without an end-of-turn signal the idle rule waits for the stall window", async () => {
|
|
269
|
+
const h = makeHarness()
|
|
270
|
+
seed(h, "s1", [item("one")])
|
|
271
|
+
await h.sup.reconcile() // schedules an evaluation; no terminal signal is ever seen
|
|
272
|
+
await sleep(5)
|
|
273
|
+
await dispatch(h, "session.tool.progress", { sessionID: "s1", id: "call_1", metadata: { shellID: "sh" } })
|
|
274
|
+
await sleep(150)
|
|
275
|
+
expect(h.calls.length).toBe(0)
|
|
276
|
+
await sleep(900) // stall window (0.005 min = 300ms) + timer epsilon
|
|
277
|
+
expect(h.calls.length).toBe(1)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test("a busy synthetic call (409) is retried and delivered", async () => {
|
|
281
|
+
const h = makeHarness()
|
|
282
|
+
h.failures.remaining = 1
|
|
283
|
+
seed(h, "s1", [item("one")])
|
|
284
|
+
await idle(h)
|
|
285
|
+
await sleep(150)
|
|
286
|
+
expect(h.calls.length).toBe(1)
|
|
287
|
+
expect(h.logs.some((line) => line.includes("nudge busy"))).toBe(true)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test("a storage failure during refresh is logged and supervision continues (fail-open)", async () => {
|
|
291
|
+
const h = makeHarness()
|
|
292
|
+
seed(h, "s1", [item("one")])
|
|
293
|
+
h.state.failRefresh = true
|
|
294
|
+
await idle(h)
|
|
295
|
+
await sleep(80)
|
|
296
|
+
expect(h.calls.length).toBe(1)
|
|
297
|
+
expect(h.logs.some((line) => line.includes("refresh failed"))).toBe(true)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test("supervises markdown-only sessions and names path:line in the nudge", async () => {
|
|
301
|
+
const h = makeHarness()
|
|
302
|
+
seedSession(h, "s1")
|
|
303
|
+
subscribe(h, "s1", ["/proj/TODO.md"])
|
|
304
|
+
h.mdFiles.set("/proj/TODO.md", mdFile("/proj/TODO.md", [[3, "file task", "open"], [9, "already done", "completed"]]))
|
|
305
|
+
await idle(h)
|
|
306
|
+
await sleep(80)
|
|
307
|
+
expect(h.calls.length).toBe(1)
|
|
308
|
+
const text = h.calls[0]!.text
|
|
309
|
+
expect(text).toContain("Idle nudge #1")
|
|
310
|
+
expect(text).toContain("1 of 2 todos left")
|
|
311
|
+
expect(text).toContain("- [ ] file task (TODO.md:3)")
|
|
312
|
+
expect(text).not.toContain("already done")
|
|
313
|
+
expect(text).toContain("edit the subscribed file and mark items")
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test("markdown completion stops nudges and new items resume them", async () => {
|
|
317
|
+
const h = makeHarness()
|
|
318
|
+
seedSession(h, "s1")
|
|
319
|
+
subscribe(h, "s1", ["/proj/TODO.md"])
|
|
320
|
+
h.mdFiles.set("/proj/TODO.md", mdFile("/proj/TODO.md", [[1, "task", "completed"]]))
|
|
321
|
+
await idle(h)
|
|
322
|
+
await sleep(80)
|
|
323
|
+
expect(h.calls.length).toBe(0)
|
|
324
|
+
h.mdFiles.set("/proj/TODO.md", mdFile("/proj/TODO.md", [[1, "task", "completed"], [2, "new", "open"]]))
|
|
325
|
+
await idle(h)
|
|
326
|
+
await sleep(80)
|
|
327
|
+
expect(h.calls.length).toBe(1)
|
|
328
|
+
expect(h.calls[0]!.text).toContain("TODO.md:2")
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test("an unsubscribe shadow excludes the file from supervision", async () => {
|
|
332
|
+
const h = makeHarness()
|
|
333
|
+
seedSession(h, "s1")
|
|
334
|
+
subscribe(h, "s1", [], ["/proj/TODO.md"])
|
|
335
|
+
h.mdFiles.set("/proj/TODO.md", mdFile("/proj/TODO.md", [[1, "task", "open"]]))
|
|
336
|
+
await idle(h)
|
|
337
|
+
await sleep(80)
|
|
338
|
+
expect(h.calls.length).toBe(0)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test("a missing file is a valid subscription with 0 tasks, logged once", async () => {
|
|
342
|
+
const h = makeHarness()
|
|
343
|
+
seedSession(h, "s1")
|
|
344
|
+
subscribe(h, "s1", ["/proj/GONE.md"])
|
|
345
|
+
await idle(h)
|
|
346
|
+
await sleep(80)
|
|
347
|
+
expect(h.calls.length).toBe(0)
|
|
348
|
+
await idle(h)
|
|
349
|
+
await sleep(80)
|
|
350
|
+
expect(h.logs.filter((line) => line.includes("markdown file missing")).length).toBe(1)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test("nudges group session items first, then file sections", async () => {
|
|
354
|
+
const h = makeHarness()
|
|
355
|
+
seed(h, "s1", [item("session task")])
|
|
356
|
+
subscribe(h, "s1", ["/proj/TODO.md"])
|
|
357
|
+
h.mdFiles.set("/proj/TODO.md", mdFile("/proj/TODO.md", [[5, "file task", "open"]]))
|
|
358
|
+
await idle(h)
|
|
359
|
+
await sleep(80)
|
|
360
|
+
expect(h.calls.length).toBe(1)
|
|
361
|
+
const text = h.calls[0]!.text
|
|
362
|
+
expect(text).toContain("2 of 2 todos left")
|
|
363
|
+
expect(text.indexOf("- [ ] session task")).toBeLessThan(text.indexOf("TODO.md:5"))
|
|
364
|
+
expect(text).toContain("todo_write")
|
|
365
|
+
expect(text).toContain("editing the file")
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test("debugEvents logs why an evaluation was skipped (setEnabled gate)", async () => {
|
|
369
|
+
const h = makeHarness(shared(), { debugEvents: true })
|
|
370
|
+
seedSession(h, "s1")
|
|
371
|
+
subscribe(h, "s1", ["/proj/TODO.md"])
|
|
372
|
+
h.mdFiles.set("/proj/TODO.md", mdFile("/proj/TODO.md", [[1, "task", "open"]]))
|
|
373
|
+
h.sup.setEnabled("s1", false)
|
|
374
|
+
await idle(h)
|
|
375
|
+
await sleep(80)
|
|
376
|
+
expect(h.calls.length).toBe(0)
|
|
377
|
+
expect(h.logs.some((line) => line.includes("skipped (paused (disabled))"))).toBe(true)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test("streaming activity (thinking/preparing) postpones the idle nudge", async () => {
|
|
381
|
+
const h = makeHarness(shared(), { settleMs: 120, minNudgeIntervalMs: 200 })
|
|
382
|
+
seed(h, "s1", [item("one")])
|
|
383
|
+
await idle(h)
|
|
384
|
+
for (let index = 0; index < 5; index += 1) {
|
|
385
|
+
await sleep(40)
|
|
386
|
+
await dispatch(h, "session.reasoning.delta", { sessionID: "s1", text: "thinking…" })
|
|
387
|
+
}
|
|
388
|
+
expect(h.calls.length).toBe(0)
|
|
389
|
+
await sleep(900)
|
|
390
|
+
expect(h.calls.length).toBe(1)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test("setEnabled(false) stops automatic nudges (disable cycle)", async () => {
|
|
394
|
+
const h = makeHarness(shared(), { settleMs: 60 })
|
|
395
|
+
seed(h, "s1", [item("one")])
|
|
396
|
+
await idle(h)
|
|
397
|
+
h.sup.setEnabled("s1", false)
|
|
398
|
+
await sleep(150)
|
|
399
|
+
expect(h.calls.length).toBe(0)
|
|
400
|
+
h.sup.setEnabled("s1", true)
|
|
401
|
+
await sleep(150)
|
|
402
|
+
expect(h.calls.length).toBe(1)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test("manual nudge: blocked while disabled, allowed over an interrupt pause", async () => {
|
|
406
|
+
const h = makeHarness(shared(), { settleMs: 20 })
|
|
407
|
+
seed(h, "s1", [item("one")])
|
|
408
|
+
h.sup.setEnabled("s1", false)
|
|
409
|
+
h.sup.forceNudge("s1")
|
|
410
|
+
await sleep(60)
|
|
411
|
+
expect(h.calls.length).toBe(0)
|
|
412
|
+
|
|
413
|
+
h.sup.setEnabled("s1", true)
|
|
414
|
+
await dispatch(h, "session.execution.interrupted", { sessionID: "s1" })
|
|
415
|
+
expect(h.sup.getState("s1")?.paused).toBe(true)
|
|
416
|
+
h.sup.forceNudge("s1")
|
|
417
|
+
await sleep(60)
|
|
418
|
+
expect(h.calls.length).toBe(1)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test("session activity postpones the stall nudge while work runs", async () => {
|
|
422
|
+
const h = makeHarness()
|
|
423
|
+
seed(h, "s1", [item("one")])
|
|
424
|
+
await shellCreated(h, "s1", "sh_busy", "sleep 600")
|
|
425
|
+
await idle(h)
|
|
426
|
+
await sleep(150)
|
|
427
|
+
await dispatch(h, "session.reasoning.delta", { sessionID: "s1", text: "still working…" })
|
|
428
|
+
await sleep(150)
|
|
429
|
+
expect(h.calls.length).toBe(0)
|
|
430
|
+
await sleep(900)
|
|
431
|
+
expect(h.calls.length).toBe(1)
|
|
432
|
+
expect(h.calls[0]!.text).toContain('background shell "sleep 600"')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test("nudges stop after the list completes and resume on new items", async () => {
|
|
436
|
+
const h = makeHarness()
|
|
437
|
+
seed(h, "s1", [item("one")])
|
|
438
|
+
await idle(h)
|
|
439
|
+
await sleep(80)
|
|
440
|
+
expect(h.calls.length).toBe(1)
|
|
441
|
+
h.state.todos.set("s1", [item("one", "completed")])
|
|
442
|
+
await idle(h)
|
|
443
|
+
await sleep(80)
|
|
444
|
+
expect(h.calls.length).toBe(1)
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
describe("running work & stall (aspect 8)", () => {
|
|
449
|
+
test("no nudge while a child session is active; stall nudge names the child", async () => {
|
|
450
|
+
const h = makeHarness()
|
|
451
|
+
seed(h, "s1", [item("one")])
|
|
452
|
+
await dispatch(h, "session.created", { sessionID: "c1", projectID: "proj-1", parentID: "s1" })
|
|
453
|
+
await dispatch(h, "session.execution.started", { sessionID: "c1" })
|
|
454
|
+
await idle(h)
|
|
455
|
+
for (let i = 0; i < 6; i++) {
|
|
456
|
+
await sleep(150)
|
|
457
|
+
await dispatch(h, "session.tool.progress", { sessionID: "c1", id: "call_1", metadata: { shellID: "sh" } })
|
|
458
|
+
}
|
|
459
|
+
expect(h.calls.length).toBe(0)
|
|
460
|
+
await sleep(1000) // child goes quiet → stall
|
|
461
|
+
expect(h.calls.length).toBe(1)
|
|
462
|
+
const call = h.calls[0]!
|
|
463
|
+
expect(call.text).toContain("Stall nudge #1")
|
|
464
|
+
expect(call.text).toContain("subagent c1")
|
|
465
|
+
expect(call.text).toContain("1 of 1 todo left")
|
|
466
|
+
expect(call.text).toContain("no activity for less than a minute")
|
|
467
|
+
expect(call.text).toContain("stall threshold")
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
test("background shell: stall after silence, cleared by shell.exited", async () => {
|
|
471
|
+
const h = makeHarness()
|
|
472
|
+
seed(h, "s1", [item("one")])
|
|
473
|
+
await shellCreated(h, "s1", "sh_t1", "sleep 600")
|
|
474
|
+
await idle(h)
|
|
475
|
+
await sleep(900)
|
|
476
|
+
expect(h.calls.length).toBe(1)
|
|
477
|
+
expect(h.calls[0]!.text).toContain('background shell "sleep 600"')
|
|
478
|
+
|
|
479
|
+
const h2 = makeHarness()
|
|
480
|
+
seed(h2, "s1", [item("one")])
|
|
481
|
+
await shellCreated(h2, "s1", "sh_t2", "sleep 600")
|
|
482
|
+
await dispatch(h2, "shell.exited", { id: "sh_t2", status: "exited", exit: 0 })
|
|
483
|
+
await idle(h2)
|
|
484
|
+
await sleep(900)
|
|
485
|
+
expect(h2.calls.length).toBe(1)
|
|
486
|
+
expect(h2.calls[0]!.text).toContain("Idle nudge #1")
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test("shell output (tool.progress shellID) resets the stall clock", async () => {
|
|
490
|
+
const h = makeHarness()
|
|
491
|
+
seed(h, "s1", [item("one")])
|
|
492
|
+
await shellCreated(h, "s1", "sh_t3", "build --watch")
|
|
493
|
+
await idle(h)
|
|
494
|
+
for (let i = 0; i < 6; i++) {
|
|
495
|
+
await sleep(150)
|
|
496
|
+
await dispatch(h, "session.tool.progress", { sessionID: "s1", id: "call_x", metadata: { shellID: "sh_t3" } })
|
|
497
|
+
}
|
|
498
|
+
expect(h.calls.length).toBe(0)
|
|
499
|
+
await sleep(1000)
|
|
500
|
+
expect(h.calls.length).toBe(1)
|
|
501
|
+
expect(h.calls[0]!.text).toContain('background shell "build --watch"')
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test("exited or foreign shells never enter stall logic", async () => {
|
|
505
|
+
const h = makeHarness()
|
|
506
|
+
seed(h, "s1", [item("one")])
|
|
507
|
+
await shellCreated(h, "s1", "sh_fg", "ls")
|
|
508
|
+
await dispatch(h, "shell.exited", { id: "sh_fg", status: "exited", exit: 0 })
|
|
509
|
+
await shellCreated(h, "someone-else", "sh_foreign", "sleep 600")
|
|
510
|
+
await idle(h)
|
|
511
|
+
await sleep(900)
|
|
512
|
+
expect(h.calls.length).toBe(1)
|
|
513
|
+
expect(h.calls[0]!.text).toContain("Idle nudge #1")
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
describe("guardrails & lifecycle (aspect 9)", () => {
|
|
518
|
+
test("permission prompt blocks nudges; reply resumes", async () => {
|
|
519
|
+
const h = makeHarness()
|
|
520
|
+
seed(h, "s1", [item("one")])
|
|
521
|
+
await dispatch(h, "permission.asked", { sessionID: "s1", id: "per_1" })
|
|
522
|
+
await idle(h)
|
|
523
|
+
await sleep(80)
|
|
524
|
+
expect(h.calls.length).toBe(0)
|
|
525
|
+
await dispatch(h, "permission.replied", { sessionID: "s1", requestID: "per_1", reply: "once" })
|
|
526
|
+
await sleep(80)
|
|
527
|
+
expect(h.calls.length).toBe(1)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test("interrupt pauses; a real user prompt resumes; synthetic inbox items do not", async () => {
|
|
531
|
+
const h = makeHarness()
|
|
532
|
+
seed(h, "s1", [item("one")])
|
|
533
|
+
await dispatch(h, "session.execution.interrupted", { sessionID: "s1" })
|
|
534
|
+
await idle(h)
|
|
535
|
+
await sleep(80)
|
|
536
|
+
expect(h.calls.length).toBe(0)
|
|
537
|
+
await dispatch(h, "session.inbox.enqueued", {
|
|
538
|
+
sessionID: "s1",
|
|
539
|
+
item: { type: "synthetic", payload: { text: "nudge" } },
|
|
540
|
+
delivery: "queue",
|
|
541
|
+
})
|
|
542
|
+
await dispatch(h, "session.inbox.delivered", { sessionID: "s1", inboxID: "in_synthetic" })
|
|
543
|
+
await sleep(80)
|
|
544
|
+
expect(h.sup.getState("s1")?.paused).toBe(true)
|
|
545
|
+
expect(h.calls.length).toBe(0)
|
|
546
|
+
|
|
547
|
+
await dispatch(h, "session.inbox.enqueued", {
|
|
548
|
+
sessionID: "s1",
|
|
549
|
+
item: { type: "user", payload: { text: "hello" } },
|
|
550
|
+
delivery: "queue",
|
|
551
|
+
})
|
|
552
|
+
expect(h.sup.getState("s1")?.paused).toBe(false)
|
|
553
|
+
await dispatch(h, "session.inbox.delivered", { sessionID: "s1", inboxID: "in_user" })
|
|
554
|
+
await idle(h)
|
|
555
|
+
await sleep(80)
|
|
556
|
+
expect(h.calls.length).toBe(1)
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
test("no-progress backstop pauses after maxNoProgressNudges identical nudges", async () => {
|
|
560
|
+
const h = makeHarness(undefined, { maxNoProgressNudges: 2, minNudgeIntervalMs: 60 })
|
|
561
|
+
seed(h, "s1", [item("one")])
|
|
562
|
+
await idle(h)
|
|
563
|
+
await sleep(100)
|
|
564
|
+
expect(h.calls.length).toBe(1)
|
|
565
|
+
await idle(h)
|
|
566
|
+
await sleep(100)
|
|
567
|
+
expect(h.calls.length).toBe(2)
|
|
568
|
+
await idle(h)
|
|
569
|
+
await sleep(100)
|
|
570
|
+
expect(h.calls.length).toBe(2)
|
|
571
|
+
expect(h.logs.some((line) => line.includes("backing off"))).toBe(true)
|
|
572
|
+
// a list change resets the backstop
|
|
573
|
+
h.state.todos.set("s1", [item("two")])
|
|
574
|
+
h.sup.notifyTodosChanged("s1")
|
|
575
|
+
await sleep(100)
|
|
576
|
+
expect(h.calls.length).toBe(3)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
test("session.deleted drops registry, storage and claims", async () => {
|
|
580
|
+
const h = makeHarness()
|
|
581
|
+
seed(h, "s1", [item("one")])
|
|
582
|
+
expect(h.sup.getState("s1")).toBeDefined()
|
|
583
|
+
await dispatch(h, "session.deleted", { sessionID: "s1" })
|
|
584
|
+
expect(h.sup.getState("s1")).toBeUndefined()
|
|
585
|
+
expect(h.state.todos.has("s1")).toBe(false)
|
|
586
|
+
expect(h.state.claims.has("claims/s1")).toBe(false)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
test("dispose stops all pending work", async () => {
|
|
590
|
+
const h = makeHarness()
|
|
591
|
+
seed(h, "s1", [item("one")])
|
|
592
|
+
await idle(h)
|
|
593
|
+
h.sup.dispose()
|
|
594
|
+
await sleep(100)
|
|
595
|
+
expect(h.calls.length).toBe(0)
|
|
596
|
+
await idle(h) // post-dispose events are ignored
|
|
597
|
+
await sleep(50)
|
|
598
|
+
expect(h.calls.length).toBe(0)
|
|
599
|
+
})
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
describe("registry & scope (aspect 6)", () => {
|
|
603
|
+
test("sessions from other projects are ignored", async () => {
|
|
604
|
+
const h = makeHarness()
|
|
605
|
+
await dispatch(h, "session.created", { sessionID: "x1", projectID: "other-proj" })
|
|
606
|
+
expect(h.sup.getState("x1")).toBeUndefined()
|
|
607
|
+
h.state.todos.set("x1", [item("one")])
|
|
608
|
+
await idle(h, "x1")
|
|
609
|
+
await sleep(80)
|
|
610
|
+
expect(h.calls.length).toBe(0)
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test("scope 'all' registers foreign projects", async () => {
|
|
614
|
+
const h = makeHarness(undefined, { scope: "all" })
|
|
615
|
+
await dispatch(h, "session.created", { sessionID: "x1", projectID: "other-proj" })
|
|
616
|
+
expect(h.sup.getState("x1")).toBeDefined()
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test("unknown event types are ignored without errors", async () => {
|
|
620
|
+
const h = makeHarness()
|
|
621
|
+
await dispatch(h, "session.teleported", { sessionID: "s1" })
|
|
622
|
+
await dispatch(h, "mystery.event", {})
|
|
623
|
+
expect(h.logs.filter((line) => line.includes("event handler error")).length).toBe(0)
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
test("newer supervisor generations win the session via ownership claims", async () => {
|
|
627
|
+
const state = shared()
|
|
628
|
+
const older = makeHarness(state, {}, "gen-a")
|
|
629
|
+
const newer = makeHarness(state, {}, "gen-b")
|
|
630
|
+
older.state.todos.set("s1", [item("one")])
|
|
631
|
+
older.state.sessions.set("s1", { projectID: "proj-1" })
|
|
632
|
+
older.sup.registerSession("s1", { projectID: "proj-1" })
|
|
633
|
+
await sleep(5)
|
|
634
|
+
newer.sup.registerSession("s1", { projectID: "proj-1" })
|
|
635
|
+
await idle(older)
|
|
636
|
+
await sleep(80)
|
|
637
|
+
expect(older.calls.length).toBe(0)
|
|
638
|
+
expect(older.logs.some((line) => line.includes("newer supervisor owns"))).toBe(true)
|
|
639
|
+
await idle(newer)
|
|
640
|
+
await sleep(80)
|
|
641
|
+
expect(newer.calls.length).toBe(1)
|
|
642
|
+
})
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// §12 idle model: activity spans. A session is nudged only when nothing the
|
|
646
|
+
// runtime is doing is open and a confirmed end-of-turn signal was seen.
|
|
647
|
+
describe("activity spans & the idle model (§12)", () => {
|
|
648
|
+
const started = (h: H, sessionID = "s1") => dispatch(h, "session.step.started", { sessionID })
|
|
649
|
+
const toolStart = (h: H, id: string, name: string, sessionID = "s1") =>
|
|
650
|
+
dispatch(h, "session.tool.input.started", { sessionID, id, name })
|
|
651
|
+
const toolCalled = (h: H, id: string, input: Record<string, unknown>, sessionID = "s1") =>
|
|
652
|
+
dispatch(h, "session.tool.called", { sessionID, id, input })
|
|
653
|
+
const stepEnd = (h: H, finish: string, sessionID = "s1") =>
|
|
654
|
+
dispatch(h, "session.step.ended", { sessionID, finish })
|
|
655
|
+
|
|
656
|
+
test("an in-flight task tool blocks the idle nudge; stopping the turn arms it", async () => {
|
|
657
|
+
const h = makeHarness()
|
|
658
|
+
seed(h, "s1", [item("one")])
|
|
659
|
+
await idle(h)
|
|
660
|
+
await started(h)
|
|
661
|
+
await toolStart(h, "call_task", "task")
|
|
662
|
+
await toolCalled(h, "call_task", { description: "Add the panel", subagent_type: "general" })
|
|
663
|
+
await sleep(150)
|
|
664
|
+
expect(h.calls.length).toBe(0)
|
|
665
|
+
|
|
666
|
+
await dispatch(h, "session.tool.success", { sessionID: "s1", id: "call_task" })
|
|
667
|
+
await stepEnd(h, "stop")
|
|
668
|
+
await sleep(120)
|
|
669
|
+
expect(h.calls.length).toBe(1)
|
|
670
|
+
expect(h.calls[0]!.text).toContain("Idle nudge #1")
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
test("finish=tool-calls keeps the turn open; only finish=stop arms the nudge", async () => {
|
|
674
|
+
const h = makeHarness()
|
|
675
|
+
seed(h, "s1", [item("one")])
|
|
676
|
+
await started(h)
|
|
677
|
+
await stepEnd(h, "tool-calls")
|
|
678
|
+
await idle(h)
|
|
679
|
+
await started(h)
|
|
680
|
+
await sleep(150)
|
|
681
|
+
expect(h.calls.length).toBe(0)
|
|
682
|
+
|
|
683
|
+
await stepEnd(h, "stop")
|
|
684
|
+
await sleep(120)
|
|
685
|
+
expect(h.calls.length).toBe(1)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
test("a reasoning-only turn (thinking) is never nudged mid-turn", async () => {
|
|
689
|
+
const h = makeHarness(undefined, { settleMs: 60 })
|
|
690
|
+
seed(h, "s1", [item("one")])
|
|
691
|
+
await idle(h)
|
|
692
|
+
await started(h)
|
|
693
|
+
await dispatch(h, "session.reasoning.started", { sessionID: "s1" })
|
|
694
|
+
for (let index = 0; index < 6; index += 1) {
|
|
695
|
+
await sleep(40)
|
|
696
|
+
await dispatch(h, "session.reasoning.delta", { sessionID: "s1", text: "thinking…" })
|
|
697
|
+
}
|
|
698
|
+
expect(h.calls.length).toBe(0)
|
|
699
|
+
|
|
700
|
+
await dispatch(h, "session.reasoning.ended", { sessionID: "s1" })
|
|
701
|
+
await stepEnd(h, "stop")
|
|
702
|
+
await sleep(120)
|
|
703
|
+
expect(h.calls.length).toBe(1)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
test("a tool call with no output for the stall window is named in the stall nudge", async () => {
|
|
707
|
+
const h = makeHarness()
|
|
708
|
+
seed(h, "s1", [item("one")])
|
|
709
|
+
await h.sup.reconcile() // arms an evaluation without any terminal signal
|
|
710
|
+
await started(h)
|
|
711
|
+
await toolStart(h, "call_task", "task")
|
|
712
|
+
await toolCalled(h, "call_task", { description: "Long subagent run" })
|
|
713
|
+
await sleep(420)
|
|
714
|
+
expect(h.calls.length).toBe(0)
|
|
715
|
+
await sleep(700)
|
|
716
|
+
expect(h.calls.length).toBe(1)
|
|
717
|
+
expect(h.calls[0]!.text).toContain("Stall nudge")
|
|
718
|
+
expect(h.calls[0]!.text).toContain('tool task "Long subagent run"')
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
test("a leaked tool/span is healed by the next end-of-turn signal", async () => {
|
|
722
|
+
const h = makeHarness()
|
|
723
|
+
seed(h, "s1", [item("one")])
|
|
724
|
+
await h.sup.reconcile()
|
|
725
|
+
await started(h)
|
|
726
|
+
await toolStart(h, "call_lost", "task")
|
|
727
|
+
// No tool.success / step.ended ever arrives for this call.
|
|
728
|
+
await idle(h)
|
|
729
|
+
await sleep(80)
|
|
730
|
+
expect(h.calls.length).toBe(1)
|
|
731
|
+
expect(h.sup.getState("s1")?.tools.size).toBe(0)
|
|
732
|
+
expect(h.sup.getState("s1")?.spans.size).toBe(0)
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
test("a child session's background shell keeps the parent out of the idle path", async () => {
|
|
736
|
+
const h = makeHarness()
|
|
737
|
+
seed(h, "s1", [item("one")])
|
|
738
|
+
h.state.sessions.set("c1", { projectID: "proj-1", parentID: "s1" })
|
|
739
|
+
await dispatch(h, "session.created", { sessionID: "c1", projectID: "proj-1", parentID: "s1" })
|
|
740
|
+
await shellCreated(h, "c1", "sh_child", "long build")
|
|
741
|
+
await idle(h)
|
|
742
|
+
await sleep(150)
|
|
743
|
+
expect(h.calls.length).toBe(0)
|
|
744
|
+
expect(h.sup.getState("s1")?.children.has("c1")).toBe(true)
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
test("a shell whose capture file keeps growing is not stalled", async () => {
|
|
748
|
+
const dir = "/tmp/opencode"
|
|
749
|
+
mkdirSync(dir, { recursive: true })
|
|
750
|
+
const file = `${dir}/capture-${process.pid}.out`
|
|
751
|
+
writeFileSync(file, "start\n")
|
|
752
|
+
const h = makeHarness()
|
|
753
|
+
seed(h, "s1", [item("one")])
|
|
754
|
+
await shellCreated(h, "s1", "sh_grow", "long build", file)
|
|
755
|
+
await idle(h)
|
|
756
|
+
// Keep writing output past one evaluation cycle: no events fire for stdout,
|
|
757
|
+
// so only the capture file reveals that the shell is alive.
|
|
758
|
+
for (let index = 0; index < 9; index += 1) {
|
|
759
|
+
await sleep(120)
|
|
760
|
+
appendFileSync(file, `line ${index}\n`)
|
|
761
|
+
}
|
|
762
|
+
expect(h.calls.length).toBe(0)
|
|
763
|
+
await sleep(1000)
|
|
764
|
+
expect(h.calls.length).toBe(1)
|
|
765
|
+
expect(h.calls[0]!.text).toContain('background shell "long build"')
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
test("a child that ended its turn stops counting as running work", async () => {
|
|
769
|
+
const h = makeHarness()
|
|
770
|
+
seed(h, "s1", [item("one")])
|
|
771
|
+
h.state.sessions.set("c1", { projectID: "proj-1", parentID: "s1" })
|
|
772
|
+
await dispatch(h, "session.created", { sessionID: "c1", projectID: "proj-1", parentID: "s1" })
|
|
773
|
+
await dispatch(h, "session.step.started", { sessionID: "c1" })
|
|
774
|
+
await dispatch(h, "session.step.ended", { sessionID: "c1", finish: "stop" })
|
|
775
|
+
await idle(h)
|
|
776
|
+
await sleep(140)
|
|
777
|
+
expect(h.calls.length).toBe(1)
|
|
778
|
+
})
|
|
779
|
+
})
|