@bybrawe/opencode-loop 0.5.28 → 0.5.29

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/scheduler-runtime-test.mjs +147 -0
  21. package/scripts/session-activity-test.mjs +95 -0
  22. package/scripts/session-status-test.mjs +220 -0
  23. package/scripts/v2-command-adapter-test.mjs +113 -0
  24. package/scripts/v2-command-runtime-test.mjs +98 -0
  25. package/scripts/v2-host-contract-test.mjs +27 -0
  26. package/scripts/v2-plugin-sentinel-test.mjs +57 -8
  27. package/scripts/v2-prompt-interval-test.mjs +171 -0
  28. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  29. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  30. package/src/index.js +2751 -1594
  31. package/src/source/legacy-v1.js +168 -1860
  32. package/src/source/opencode/command-router.js +103 -0
  33. package/src/source/opencode/commands.js +83 -0
  34. package/src/source/opencode/goal-commands.js +107 -0
  35. package/src/source/opencode/loop-commands.js +195 -0
  36. package/src/source/opencode/loop-registration.js +100 -0
  37. package/src/source/opencode/messages.js +55 -0
  38. package/src/source/opencode2/capabilities.js +3 -1
  39. package/src/source/opencode2/commands.js +60 -0
  40. package/src/source/opencode2/event-bridge.js +19 -1
  41. package/src/source/opencode2/events.js +64 -0
  42. package/src/source/opencode2/experimental.js +32 -1
  43. package/src/source/opencode2/host-contract.js +26 -0
  44. package/src/source/opencode2/prompt-runtime.js +381 -0
  45. package/src/source/opencode2/runtime-adapter.js +56 -0
  46. package/src/source/opencode2/status.js +40 -0
  47. package/src/source/runtime/compaction.js +122 -0
  48. package/src/source/runtime/executor.js +558 -0
  49. package/src/source/runtime/goal-policy.js +77 -0
  50. package/src/source/runtime/goal-runtime.js +231 -0
  51. package/src/source/runtime/goal-steering.js +198 -0
  52. package/src/source/runtime/job-workspace.js +148 -0
  53. package/src/source/runtime/scheduler.js +212 -0
  54. package/src/source/runtime/session-activity.js +112 -0
  55. package/src/source/runtime/session-status.js +197 -0
  56. package/scripts/host-bridge-parity-test.mjs +0 -22
@@ -0,0 +1,433 @@
1
+ import assert from "node:assert/strict"
2
+ import { createServer } from "node:http"
3
+ import net from "node:net"
4
+ import { execFileSync, spawn } from "node:child_process"
5
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import process from "node:process"
9
+ import { fileURLToPath, pathToFileURL } from "node:url"
10
+
11
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
+ const LOOP_OBJECTIVE = "real OpenCode 2 loop canary"
13
+ const EXPECTED_TURNS = 2
14
+ const EXPECTED_COMMANDS = ["loop", "loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear"]
15
+ const SERVER_USERNAME = "opencode"
16
+ const SERVER_PASSWORD = "opencode-loop-v2-canary"
17
+
18
+ function appendLog(current, chunk, limit = 100_000) {
19
+ return (current + String(chunk)).slice(-limit)
20
+ }
21
+
22
+ async function reservePort() {
23
+ return await new Promise((resolve, reject) => {
24
+ const server = net.createServer()
25
+ server.once("error", reject)
26
+ server.listen(0, "127.0.0.1", () => {
27
+ const address = server.address()
28
+ if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port"))
29
+ server.close((error) => error ? reject(error) : resolve(address.port))
30
+ })
31
+ })
32
+ }
33
+
34
+ async function waitForTcp(port, child, logs, timeoutMs = 30_000) {
35
+ const deadline = Date.now() + timeoutMs
36
+ while (Date.now() < deadline) {
37
+ if (child.exitCode !== null) throw new Error(`OpenCode 2 server exited before ready.\n${logs()}`)
38
+ const connected = await new Promise((resolve) => {
39
+ const socket = net.createConnection({ host: "127.0.0.1", port })
40
+ socket.once("connect", () => { socket.destroy(); resolve(true) })
41
+ socket.once("error", () => resolve(false))
42
+ socket.setTimeout(500, () => { socket.destroy(); resolve(false) })
43
+ })
44
+ if (connected) return
45
+ await new Promise((resolve) => setTimeout(resolve, 100))
46
+ }
47
+ throw new Error(`timed out waiting for OpenCode 2 server on ${port}\n${logs()}`)
48
+ }
49
+
50
+ async function stopProcess(child, timeoutMs = 5_000) {
51
+ if (!child || child.exitCode !== null) return
52
+ child.kill("SIGTERM")
53
+ await new Promise((resolve) => {
54
+ if (child.exitCode !== null) return resolve()
55
+ const timer = setTimeout(resolve, timeoutMs)
56
+ child.once("close", () => { clearTimeout(timer); resolve() })
57
+ })
58
+ }
59
+
60
+ async function waitFor(predicate, description, diagnostics, timeoutMs = 60_000) {
61
+ const deadline = Date.now() + timeoutMs
62
+ while (Date.now() < deadline) {
63
+ if (await predicate()) return
64
+ await new Promise((resolve) => setTimeout(resolve, 100))
65
+ }
66
+ throw new Error(`timed out waiting for ${description}\n${await diagnostics()}`)
67
+ }
68
+
69
+ function contentText(content) {
70
+ if (typeof content === "string") return content
71
+ if (!Array.isArray(content)) return ""
72
+ return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
73
+ }
74
+
75
+ function lastUserText(body) {
76
+ const messages = Array.isArray(body.messages) ? body.messages : []
77
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
78
+ if (messages[index]?.role === "user") return contentText(messages[index]?.content)
79
+ }
80
+ return ""
81
+ }
82
+
83
+ function streamHeaders(res) {
84
+ res.writeHead(200, {
85
+ "content-type": "text/event-stream; charset=utf-8",
86
+ "cache-control": "no-cache",
87
+ connection: "keep-alive",
88
+ })
89
+ }
90
+
91
+ function writeSse(res, value) {
92
+ res.write(`data: ${JSON.stringify(value)}\n\n`)
93
+ }
94
+
95
+ function streamText(res, content, sequence) {
96
+ const id = `chatcmpl-v2-loop-${sequence}`
97
+ const created = Math.floor(Date.now() / 1000)
98
+ streamHeaders(res)
99
+ writeSse(res, {
100
+ id,
101
+ object: "chat.completion.chunk",
102
+ created,
103
+ model: "canary",
104
+ choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }],
105
+ })
106
+ writeSse(res, {
107
+ id,
108
+ object: "chat.completion.chunk",
109
+ created,
110
+ model: "canary",
111
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
112
+ usage: { prompt_tokens: 30, completion_tokens: 4, total_tokens: 34 },
113
+ })
114
+ res.end("data: [DONE]\n\n")
115
+ }
116
+
117
+ function startProvider() {
118
+ const stats = { chatRequests: 0, loopRequests: 0, paths: [] }
119
+ const server = createServer(async (req, res) => {
120
+ const url = new URL(req.url ?? "/", "http://127.0.0.1")
121
+ stats.paths.push(`${req.method} ${url.pathname}`)
122
+ if (req.method === "GET" && url.pathname.endsWith("/models")) {
123
+ res.writeHead(200, { "content-type": "application/json" })
124
+ res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] }))
125
+ return
126
+ }
127
+ if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
128
+ res.writeHead(404, { "content-type": "application/json" })
129
+ res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } }))
130
+ return
131
+ }
132
+
133
+ let raw = ""
134
+ for await (const chunk of req) raw += String(chunk)
135
+ const body = raw ? JSON.parse(raw) : {}
136
+ stats.chatRequests += 1
137
+ const text = lastUserText(body)
138
+ if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION")) {
139
+ stats.loopRequests += 1
140
+ streamText(res, `V2_LOOP_TURN_${stats.loopRequests}`, stats.chatRequests)
141
+ return
142
+ }
143
+ streamText(res, "OK", stats.chatRequests)
144
+ })
145
+
146
+ return {
147
+ stats,
148
+ async listen() {
149
+ await new Promise((resolve, reject) => {
150
+ server.once("error", reject)
151
+ server.listen(0, "127.0.0.1", resolve)
152
+ })
153
+ const address = server.address()
154
+ if (!address || typeof address === "string") throw new Error("failed to start deterministic V2 provider")
155
+ return address.port
156
+ },
157
+ async close() {
158
+ await new Promise((resolve) => server.close(() => resolve()))
159
+ },
160
+ }
161
+ }
162
+
163
+ function bridgePluginSource() {
164
+ return `import { writeFile } from "node:fs/promises"
165
+
166
+ export default {
167
+ id: "bybrawe.opencode-loop.v2.loop-canary-bridge",
168
+ async setup(ctx) {
169
+ const module = await import(process.env.OPENCODE_LOOP_V2_PLUGIN_URL)
170
+ const cleanup = await module.default.setup(ctx)
171
+ await writeFile(process.env.OPENCODE_LOOP_V2_MARKER, JSON.stringify({ activated: true }, null, 2), "utf8")
172
+ return async () => {
173
+ if (typeof cleanup === "function") await cleanup()
174
+ }
175
+ },
176
+ }
177
+ `
178
+ }
179
+
180
+ function commandNames(payload) {
181
+ const data = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : []
182
+ return new Set(data.map((item) => item?.name).filter((name) => typeof name === "string"))
183
+ }
184
+
185
+ async function main() {
186
+ assert.equal(process.platform, "linux", "the real OpenCode 2 Loop canary is intentionally Ubuntu-only")
187
+
188
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
189
+ const home = path.join(workspace, ".home")
190
+ const pluginDir = path.join(workspace, ".opencode", "plugins")
191
+ const marker = path.join(workspace, "v2-real-adapter-marker.json")
192
+ const provider = startProvider()
193
+ const providerPort = await provider.listen()
194
+ let server
195
+ let serverLog = ""
196
+ let apiPrefix = null
197
+ let latestCommands = new Set()
198
+ let sessionID = ""
199
+ let commandError = null
200
+
201
+ await Promise.all([
202
+ mkdir(pluginDir, { recursive: true }),
203
+ mkdir(path.join(home, ".config"), { recursive: true }),
204
+ mkdir(path.join(home, ".local", "share"), { recursive: true }),
205
+ mkdir(path.join(home, ".local", "state"), { recursive: true }),
206
+ ])
207
+
208
+ await writeFile(path.join(pluginDir, "opencode-loop-v2-real.js"), bridgePluginSource(), "utf8")
209
+ await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
210
+ $schema: "https://opencode.ai/config.json",
211
+ model: "canary/canary",
212
+ providers: {
213
+ canary: {
214
+ name: "Deterministic OpenCode 2 Loop Canary",
215
+ package: "@opencode-ai/ai/providers/openai-compatible",
216
+ settings: { baseURL: `http://127.0.0.1:${providerPort}/v1` },
217
+ models: {
218
+ canary: {
219
+ name: "Deterministic OpenCode 2 Loop Canary",
220
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
221
+ limit: { context: 100000, output: 4096 },
222
+ },
223
+ },
224
+ },
225
+ },
226
+ }, null, 2)}\n`)
227
+
228
+ execFileSync("git", ["init", "--quiet", workspace], { stdio: "ignore" })
229
+ execFileSync("git", ["-C", workspace, "config", "user.email", "opencode-loop-ci@example.invalid"], { stdio: "ignore" })
230
+ execFileSync("git", ["-C", workspace, "config", "user.name", "OpenCode Loop CI"], { stdio: "ignore" })
231
+ execFileSync("git", ["-C", workspace, "add", "."], { stdio: "ignore" })
232
+ execFileSync("git", ["-C", workspace, "commit", "--quiet", "-m", "init"], { stdio: "ignore" })
233
+
234
+ const env = {
235
+ ...process.env,
236
+ HOME: home,
237
+ USERPROFILE: home,
238
+ XDG_CONFIG_HOME: path.join(home, ".config"),
239
+ XDG_DATA_HOME: path.join(home, ".local", "share"),
240
+ XDG_STATE_HOME: path.join(home, ".local", "state"),
241
+ XDG_CACHE_HOME: path.join(home, ".cache"),
242
+ OPENCODE_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", "source", "opencode2", "experimental.js")).href,
243
+ OPENCODE_LOOP_V2_MARKER: marker,
244
+ OPENCODE_SERVER_USERNAME: SERVER_USERNAME,
245
+ OPENCODE_SERVER_PASSWORD: SERVER_PASSWORD,
246
+ OPENCODE_DISABLE_AUTOUPDATE: "true",
247
+ OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
248
+ CI: "true",
249
+ }
250
+
251
+ const diagnostics = async () => {
252
+ let state = "missing"
253
+ if (sessionID) {
254
+ try { state = await readFile(path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`), "utf8") } catch {}
255
+ }
256
+ return [
257
+ `apiPrefix=${String(apiPrefix)}`,
258
+ `commands=${JSON.stringify([...latestCommands])}`,
259
+ `provider=${JSON.stringify(provider.stats)}`,
260
+ `sessionID=${sessionID || "none"}`,
261
+ `commandError=${String(commandError ?? "none")}`,
262
+ `state=${state}`,
263
+ `serverExit=${server?.exitCode}`,
264
+ `serverLog=${serverLog}`,
265
+ ].join("\n")
266
+ }
267
+
268
+ try {
269
+ const port = await reservePort()
270
+ server = spawn("opencode2", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
271
+ cwd: workspace,
272
+ env,
273
+ windowsHide: true,
274
+ })
275
+ server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
276
+ server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
277
+ await waitForTcp(port, server, () => serverLog)
278
+
279
+ const baseURL = `http://127.0.0.1:${port}`
280
+ const authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${SERVER_PASSWORD}`).toString("base64")}`
281
+ const request = async (pathname, init = {}, timeoutMs = 30_000) => {
282
+ const response = await fetch(`${baseURL}${pathname}`, {
283
+ ...init,
284
+ headers: {
285
+ "content-type": "application/json",
286
+ "x-opencode-directory": workspace,
287
+ authorization,
288
+ ...(init.headers ?? {}),
289
+ },
290
+ signal: init.signal ?? AbortSignal.timeout(timeoutMs),
291
+ })
292
+ const text = await response.text()
293
+ let body = null
294
+ if (text) {
295
+ try { body = JSON.parse(text) } catch { body = text }
296
+ }
297
+ return { ok: response.ok, status: response.status, body, text }
298
+ }
299
+
300
+ for (const prefix of ["/api", ""]) {
301
+ const deadline = Date.now() + 30_000
302
+ while (Date.now() < deadline) {
303
+ let response
304
+ try { response = await request(`${prefix}/command`, { method: "GET" }, 5_000) } catch {}
305
+ if (response?.ok) {
306
+ apiPrefix = prefix
307
+ break
308
+ }
309
+ if (response && ![404, 503].includes(response.status)) {
310
+ throw new Error(`command registry probe failed with HTTP ${response.status}: ${response.text}\n${await diagnostics()}`)
311
+ }
312
+ if (response?.status === 404) break
313
+ await new Promise((resolve) => setTimeout(resolve, 250))
314
+ }
315
+ if (apiPrefix !== null) break
316
+ }
317
+ assert.notEqual(apiPrefix, null, `OpenCode 2 command API never became ready\n${await diagnostics()}`)
318
+
319
+ await waitFor(async () => {
320
+ try { return Boolean(JSON.parse(await readFile(marker, "utf8"))?.activated) } catch { return false }
321
+ }, "experimental V2 plugin activation", diagnostics, 30_000)
322
+
323
+ await waitFor(async () => {
324
+ const response = await request(`${apiPrefix}/command`, { method: "GET" }, 5_000)
325
+ if (!response.ok) return false
326
+ latestCommands = commandNames(response.body)
327
+ return EXPECTED_COMMANDS.every((name) => latestCommands.has(name))
328
+ }, "plugin-registered Loop commands to enter the real V2 registry", diagnostics, 30_000)
329
+
330
+ const createdResponse = await request(`${apiPrefix}/session`, {
331
+ method: "POST",
332
+ body: JSON.stringify({ title: "OpenCode 2 Loop canary" }),
333
+ })
334
+ if (!createdResponse.ok) throw new Error(`session create failed: HTTP ${createdResponse.status} ${createdResponse.text}\n${await diagnostics()}`)
335
+ const session = createdResponse.body?.data ?? createdResponse.body
336
+ sessionID = String(session?.id ?? "")
337
+ assert.ok(sessionID, `OpenCode 2 did not create a session: ${createdResponse.text}\n${await diagnostics()}`)
338
+
339
+ const commandPromise = request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
340
+ method: "POST",
341
+ body: JSON.stringify({ command: "loop", arguments: `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}` }),
342
+ }, 120_000).catch((error) => {
343
+ commandError = error
344
+ return null
345
+ })
346
+
347
+ await waitFor(() => provider.stats.loopRequests >= EXPECTED_TURNS, `${EXPECTED_TURNS} autonomous OpenCode 2 Loop turns`, diagnostics, 90_000)
348
+ await new Promise((resolve) => setTimeout(resolve, 1_000))
349
+ assert.equal(provider.stats.loopRequests, EXPECTED_TURNS, `V2 Loop exceeded --max-runs ${EXPECTED_TURNS}\n${await diagnostics()}`)
350
+
351
+ const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
352
+ const persisted = JSON.parse(await readFile(stateFile, "utf8"))
353
+ const loop = persisted.jobs?.find((job) => job.name === "default")
354
+ assert.ok(loop, `persisted V2 Loop job was missing\n${await diagnostics()}`)
355
+ assert.equal(loop.runCount, EXPECTED_TURNS)
356
+ assert.equal(loop.enabled, false)
357
+
358
+ const commandResponse = await commandPromise
359
+ if (commandError) throw commandError
360
+ assert.ok(commandResponse?.ok, `Loop command request failed: ${commandResponse?.status} ${commandResponse?.text}\n${await diagnostics()}`)
361
+ assert.equal(server.exitCode, null, `OpenCode 2 server exited during canary\n${await diagnostics()}`)
362
+
363
+ const readPersisted = async () => {
364
+ try { return JSON.parse(await readFile(stateFile, "utf8")) }
365
+ catch (error) {
366
+ if (error?.code === "ENOENT") return { jobs: [] }
367
+ throw error
368
+ }
369
+ }
370
+ const jobNamed = async (name) => (await readPersisted()).jobs?.find((job) => String(job?.name || "default") === name)
371
+ const sendControl = async (command, argumentsText = "") => {
372
+ const response = await request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
373
+ method: "POST",
374
+ body: JSON.stringify({ command, arguments: argumentsText }),
375
+ }, 120_000)
376
+ if (!response.ok) throw new Error(`${command} failed: HTTP ${response.status} ${response.text}\n${await diagnostics()}`)
377
+ return response
378
+ }
379
+
380
+ await sendControl("loop-pause", "default")
381
+ await waitFor(async () => (await jobNamed("default"))?.paused === true, "real V2 loop-pause state mutation", diagnostics, 30_000)
382
+
383
+ await sendControl("loop-resume", "default")
384
+ await waitFor(async () => {
385
+ const job = await jobNamed("default")
386
+ return job?.paused === false && job?.lastRunAt === 0
387
+ }, "real V2 loop-resume state mutation", diagnostics, 30_000)
388
+
389
+ await sendControl("loop-stop", "default")
390
+ await waitFor(async () => !(await jobNamed("default")), "real V2 loop-stop removal", diagnostics, 30_000)
391
+
392
+ const beforeRemoveTurn = provider.stats.loopRequests
393
+ await sendControl("loop", "0s --max-runs 1 --name removable removable control lifecycle task")
394
+ await waitFor(() => provider.stats.loopRequests === beforeRemoveTurn + 1, "real V2 removable Loop turn", diagnostics, 60_000)
395
+ await waitFor(async () => {
396
+ const job = await jobNamed("removable")
397
+ return job?.runCount === 1 && job?.enabled === false
398
+ }, "real V2 removable Loop persisted completion", diagnostics, 30_000)
399
+ await sendControl("loop-remove", "removable")
400
+ await waitFor(async () => !(await jobNamed("removable")), "real V2 loop-remove removal", diagnostics, 30_000)
401
+
402
+ const beforeClearTurn = provider.stats.loopRequests
403
+ await sendControl("loop", "0s --max-runs 1 --name clearable clearable control lifecycle task")
404
+ await waitFor(() => provider.stats.loopRequests === beforeClearTurn + 1, "real V2 clearable Loop turn", diagnostics, 60_000)
405
+ await waitFor(async () => Boolean(await jobNamed("clearable")), "real V2 clearable Loop persistence", diagnostics, 30_000)
406
+ await sendControl("loop-clear")
407
+ await waitFor(async () => (await readPersisted()).jobs?.length === 0, "real V2 loop-clear state removal", diagnostics, 30_000)
408
+
409
+ assert.equal(provider.stats.loopRequests, EXPECTED_TURNS + 2, `control lifecycle should add exactly two autonomous Loop turns\n${await diagnostics()}`)
410
+ assert.equal(server.exitCode, null, `OpenCode 2 server exited during control lifecycle canary\n${await diagnostics()}`)
411
+
412
+ console.log(JSON.stringify({
413
+ ok: true,
414
+ apiPrefix,
415
+ sessionID,
416
+ registeredCommands: [...latestCommands],
417
+ loopRequests: provider.stats.loopRequests,
418
+ chatRequests: provider.stats.chatRequests,
419
+ initialRunCount: loop.runCount,
420
+ initialEnabled: loop.enabled,
421
+ controlsVerified: ["loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear"],
422
+ }, null, 2))
423
+ } finally {
424
+ await stopProcess(server)
425
+ await provider.close().catch(() => undefined)
426
+ await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
427
+ }
428
+ }
429
+
430
+ main().catch((error) => {
431
+ console.error(error?.stack || error)
432
+ process.exitCode = 1
433
+ })
@@ -0,0 +1,147 @@
1
+ import assert from "node:assert/strict"
2
+ import { createSchedulerRuntime, jobDueAt, nextDueDelay } from "../src/source/runtime/scheduler.js"
3
+
4
+ const baseJob = (overrides = {}) => ({
5
+ id: "job-1",
6
+ enabled: true,
7
+ paused: false,
8
+ maxRuns: 0,
9
+ runCount: 0,
10
+ watchPaths: [],
11
+ createdAt: new Date(0).toISOString(),
12
+ intervalMs: 100,
13
+ lastRunAt: 950,
14
+ ...overrides,
15
+ })
16
+
17
+ assert.equal(jobDueAt(baseJob(), 1_000), 1_050)
18
+ assert.equal(jobDueAt(baseJob({ intervalMs: 0 }), 1_000), 1_000)
19
+ assert.equal(jobDueAt(baseJob({ lastRunAt: 0 }), 1_000), 1_000)
20
+ assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: true, createdAt: new Date(900).toISOString(), intervalMs: 200 }), 1_000), 1_000)
21
+ assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200 }), 1_000), 1_100)
22
+ assert.equal(nextDueDelay({ jobs: [baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200 })] }, 1_000), 100)
23
+ assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200, watchPaths: ["x"], runNowRequestedAt: 999 }), 1_000), 1_000)
24
+ assert.equal(nextDueDelay({ jobs: [baseJob({ lastRunAt: 0, immediate: false, intervalMs: 200, watchPaths: ["x"], runNowRequestedAt: 999 })] }, 1_000), 0)
25
+ assert.equal(jobDueAt(baseJob({ paused: true, runNowRequestedAt: 999 }), 1_000), Infinity)
26
+ assert.equal(jobDueAt(baseJob({ enabled: false, runNowRequestedAt: 999 }), 1_000), Infinity)
27
+ assert.equal(jobDueAt(baseJob({ maxRuns: 2, runCount: 2, runNowRequestedAt: 999 }), 1_000), Infinity)
28
+ assert.equal(jobDueAt(baseJob({ kind: "goal", goalStatus: "completed", runNowRequestedAt: 999 }), 1_000), Infinity)
29
+ assert.equal(nextDueDelay({ jobs: [baseJob(), baseJob({ paused: true })] }, 1_000), 50)
30
+ assert.equal(nextDueDelay({ jobs: [baseJob({ paused: true })] }, 1_000), Infinity)
31
+
32
+ function fakeTimers() {
33
+ let id = 0
34
+ const timeouts = new Map()
35
+ const intervals = new Map()
36
+ const setTimeout = (fn, ms) => {
37
+ const timer = { id: ++id, fn, ms }
38
+ timeouts.set(timer.id, timer)
39
+ return timer
40
+ }
41
+ const clearTimeout = (timer) => { if (timer) timeouts.delete(timer.id) }
42
+ const setInterval = (fn, ms) => {
43
+ const timer = { id: ++id, fn, ms }
44
+ intervals.set(timer.id, timer)
45
+ return timer
46
+ }
47
+ const clearInterval = (timer) => { if (timer) intervals.delete(timer.id) }
48
+ const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve() }
49
+ const fireTimeout = async (timer) => {
50
+ timeouts.delete(timer.id)
51
+ timer.fn()
52
+ await flush()
53
+ }
54
+ const fireInterval = async (timer) => {
55
+ timer.fn()
56
+ await flush()
57
+ }
58
+ return { timeouts, intervals, setTimeout, clearTimeout, setInterval, clearInterval, fireTimeout, fireInterval }
59
+ }
60
+
61
+ const timers = fakeTimers()
62
+ let clock = 1_000
63
+ const events = []
64
+ const state = { jobs: [baseJob()] }
65
+ const client = { name: "fake-client" }
66
+ const runtime = createSchedulerRuntime({
67
+ now: () => clock,
68
+ readState: async () => state,
69
+ sessionIsIdle: async () => true,
70
+ finalizeActiveRun: async (_directory, _client, _sessionID, options) => events.push(options?.forceStale ? "heartbeat-finalize" : "finalize"),
71
+ maybeRunDueJobs: async (_directory, _client, _sessionID, options) => events.push(options?.heartbeat ? "heartbeat-run" : "run"),
72
+ appendLoopLog: async () => {},
73
+ toast: async () => {},
74
+ errorMessage: (error) => error?.message || String(error),
75
+ setTimeout: timers.setTimeout,
76
+ clearTimeout: timers.clearTimeout,
77
+ setInterval: timers.setInterval,
78
+ clearInterval: timers.clearInterval,
79
+ idleDebounceMs: 5,
80
+ busyRetryMs: 7,
81
+ minDueTimerMs: 1,
82
+ maxDueTimerMs: 1_000,
83
+ heartbeatMs: 10,
84
+ sessionTtlMs: 1_000,
85
+ })
86
+
87
+ runtime.rememberSession("/workspace", client, "ses-1")
88
+ runtime.rememberSession("/workspace", client, "ses-1")
89
+ assert.equal(runtime.knownSessionCount(), 1)
90
+ assert.equal(timers.intervals.size, 1, "heartbeat must be deduplicated")
91
+
92
+ runtime.scheduleIdleWork("/workspace", client, "ses-1")
93
+ runtime.scheduleIdleWork("/workspace", client, "ses-1")
94
+ assert.equal(timers.timeouts.size, 1, "idle timer must replace the previous timer")
95
+ const idleTimer = [...timers.timeouts.values()][0]
96
+ assert.equal(idleTimer.ms, 5)
97
+ await timers.fireTimeout(idleTimer)
98
+ assert.deepEqual(events.splice(0), ["finalize", "run"])
99
+
100
+ await runtime.scheduleDueWork("/workspace", client, "ses-1")
101
+ assert.equal(timers.intervals.size, 2, "watchdog should run alongside heartbeat")
102
+ const dueTimer = [...timers.timeouts.values()][0]
103
+ assert.equal(dueTimer.ms, 50)
104
+ await timers.fireTimeout(dueTimer)
105
+ assert.deepEqual(events.splice(0), ["finalize", "run"])
106
+
107
+ const heartbeat = [...timers.intervals.values()].find((timer) => timer.ms === 10)
108
+ assert.ok(heartbeat)
109
+ await timers.fireInterval(heartbeat)
110
+ assert.deepEqual(events.splice(0), ["heartbeat-finalize", "heartbeat-run"])
111
+
112
+ await runtime.scheduleDueWork("/workspace", client, "ses-1")
113
+ assert.ok(timers.timeouts.size > 0)
114
+ runtime.clearSessionScheduling("ses-1")
115
+ assert.equal(runtime.knownSessionCount(), 0)
116
+ assert.equal(timers.timeouts.size, 0)
117
+ assert.equal(timers.intervals.size, 0, "clearing the last session should stop watchdog and heartbeat")
118
+
119
+ const busyTimers = fakeTimers()
120
+ let idleChecks = 0
121
+ const busyRuntime = createSchedulerRuntime({
122
+ now: () => 2_000,
123
+ readState: async () => ({ jobs: [baseJob({ intervalMs: 0, lastRunAt: 0 })] }),
124
+ sessionIsIdle: async () => (++idleChecks > 1),
125
+ finalizeActiveRun: async () => { throw new Error("finalize should not run while first idle check is busy") },
126
+ maybeRunDueJobs: async () => {},
127
+ appendLoopLog: async () => {},
128
+ toast: async () => {},
129
+ setTimeout: busyTimers.setTimeout,
130
+ clearTimeout: busyTimers.clearTimeout,
131
+ setInterval: busyTimers.setInterval,
132
+ clearInterval: busyTimers.clearInterval,
133
+ idleDebounceMs: 3,
134
+ busyRetryMs: 7,
135
+ minDueTimerMs: 1,
136
+ heartbeatMs: 10,
137
+ })
138
+
139
+ busyRuntime.scheduleIdleWork("/workspace", client, "ses-busy")
140
+ const busyIdleTimer = [...busyTimers.timeouts.values()][0]
141
+ await busyTimers.fireTimeout(busyIdleTimer)
142
+ const retryTimer = [...busyTimers.timeouts.values()][0]
143
+ assert.ok(retryTimer)
144
+ assert.equal(retryTimer.ms, 7, "busy idle handler should reschedule with busy retry delay")
145
+ busyRuntime.clearSessionScheduling("ses-busy")
146
+
147
+ console.log("scheduler runtime tests passed")
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict"
2
+ import {
3
+ activeToolCalls,
4
+ sessionParents,
5
+ sessionStatuses,
6
+ sessionStatusSeenAt,
7
+ hasActiveToolCalls,
8
+ markToolCallActive,
9
+ markToolCallFinished,
10
+ updateSessionRelationship,
11
+ updateSessionRelationshipFromEvent,
12
+ isDescendantSession,
13
+ hasBusyDescendant,
14
+ refreshSessionRelationships,
15
+ updateToolActivityFromEvent,
16
+ clearSessionActivity,
17
+ } from "../src/source/runtime/session-activity.js"
18
+
19
+ const parentID = "ses_parent"
20
+ const childID = "ses_child"
21
+ const listedChildID = "ses_listed_child"
22
+
23
+ for (const sessionID of [parentID, childID, listedChildID]) clearSessionActivity(sessionID)
24
+
25
+ updateSessionRelationship({ id: childID, parentID })
26
+ assert.equal(sessionParents.get(childID), parentID)
27
+ assert.equal(isDescendantSession(childID, parentID), true)
28
+ assert.equal(isDescendantSession(parentID, childID), false)
29
+
30
+ markToolCallActive({ sessionID: childID, callID: "call_1" })
31
+ assert.equal(hasActiveToolCalls(childID), true)
32
+ assert.equal(sessionStatuses.get(childID), "busy")
33
+ assert.ok((sessionStatusSeenAt.get(childID) || 0) > 0)
34
+ assert.equal(hasBusyDescendant(parentID), true)
35
+
36
+ markToolCallFinished({ sessionID: childID, callID: "call_1" })
37
+ assert.equal(hasActiveToolCalls(childID), false)
38
+ sessionStatuses.set(childID, "idle")
39
+ assert.equal(hasBusyDescendant(parentID), false)
40
+
41
+ updateToolActivityFromEvent({
42
+ type: "message.part.updated",
43
+ properties: {
44
+ part: {
45
+ type: "tool",
46
+ sessionID: childID,
47
+ callID: "call_2",
48
+ id: "part_2",
49
+ state: { status: "running" },
50
+ },
51
+ },
52
+ })
53
+ assert.equal(activeToolCalls.get(childID)?.has("call_2"), true)
54
+
55
+ updateToolActivityFromEvent({
56
+ type: "message.part.updated",
57
+ properties: {
58
+ part: {
59
+ type: "tool",
60
+ sessionID: childID,
61
+ callID: "call_2",
62
+ id: "part_2",
63
+ state: { status: "completed" },
64
+ },
65
+ },
66
+ })
67
+ assert.equal(hasActiveToolCalls(childID), false)
68
+
69
+ await refreshSessionRelationships({
70
+ session: {
71
+ list: async () => [{ id: listedChildID, parentID }],
72
+ },
73
+ }, "/tmp/opencode-loop-session-activity")
74
+ assert.equal(sessionParents.get(listedChildID), parentID)
75
+
76
+ updateSessionRelationshipFromEvent({
77
+ type: "session.deleted",
78
+ properties: { info: { id: listedChildID, parentID } },
79
+ })
80
+ assert.equal(sessionParents.has(listedChildID), false)
81
+
82
+ sessionParents.set(parentID, childID)
83
+ sessionParents.set(childID, parentID)
84
+ assert.equal(isDescendantSession(parentID, "ses_missing"), false)
85
+ sessionParents.delete(parentID)
86
+
87
+ clearSessionActivity(childID)
88
+ assert.equal(activeToolCalls.has(childID), false)
89
+ assert.equal(sessionParents.has(childID), false)
90
+ assert.equal(sessionStatuses.has(childID), false)
91
+ assert.equal(sessionStatusSeenAt.has(childID), false)
92
+ clearSessionActivity(parentID)
93
+ clearSessionActivity(listedChildID)
94
+
95
+ console.log("session activity runtime tests passed")