@bybrawe/opencode-loop 0.5.28 → 0.5.30

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 (59) hide show
  1. package/CHANGELOG.md +22 -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/publish-workflow-test.mjs +25 -0
  21. package/scripts/scheduler-runtime-test.mjs +147 -0
  22. package/scripts/session-activity-test.mjs +95 -0
  23. package/scripts/session-status-test.mjs +220 -0
  24. package/scripts/v2-command-adapter-test.mjs +113 -0
  25. package/scripts/v2-command-runtime-test.mjs +98 -0
  26. package/scripts/v2-diagnostics-test.mjs +74 -0
  27. package/scripts/v2-host-contract-test.mjs +27 -0
  28. package/scripts/v2-plugin-sentinel-test.mjs +60 -8
  29. package/scripts/v2-prompt-interval-test.mjs +171 -0
  30. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  31. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  32. package/src/index.js +2751 -1594
  33. package/src/source/legacy-v1.js +168 -1860
  34. package/src/source/opencode/command-router.js +103 -0
  35. package/src/source/opencode/commands.js +83 -0
  36. package/src/source/opencode/goal-commands.js +107 -0
  37. package/src/source/opencode/loop-commands.js +195 -0
  38. package/src/source/opencode/loop-registration.js +100 -0
  39. package/src/source/opencode/messages.js +55 -0
  40. package/src/source/opencode2/capabilities.js +3 -1
  41. package/src/source/opencode2/commands.js +72 -0
  42. package/src/source/opencode2/diagnostics.js +80 -0
  43. package/src/source/opencode2/event-bridge.js +19 -1
  44. package/src/source/opencode2/events.js +64 -0
  45. package/src/source/opencode2/experimental.js +41 -1
  46. package/src/source/opencode2/host-contract.js +26 -0
  47. package/src/source/opencode2/prompt-runtime.js +381 -0
  48. package/src/source/opencode2/runtime-adapter.js +56 -0
  49. package/src/source/opencode2/status.js +40 -0
  50. package/src/source/runtime/compaction.js +122 -0
  51. package/src/source/runtime/executor.js +558 -0
  52. package/src/source/runtime/goal-policy.js +77 -0
  53. package/src/source/runtime/goal-runtime.js +231 -0
  54. package/src/source/runtime/goal-steering.js +198 -0
  55. package/src/source/runtime/job-workspace.js +148 -0
  56. package/src/source/runtime/scheduler.js +212 -0
  57. package/src/source/runtime/session-activity.js +112 -0
  58. package/src/source/runtime/session-status.js +197 -0
  59. package/scripts/host-bridge-parity-test.mjs +0 -22
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.30
4
+
5
+ - Harden npm publishing so a one-shot release publishes the immutable trigger SHA, verifies that commit belongs to `main`, serializes releases, confirms the package is readable from npm, and tags exactly the published snapshot.
6
+ - Extend the experimental OpenCode 2 command registry with read-only `/loop-export`, `/loop-help`, and `/loop-doctor` diagnostics.
7
+ - Keep OpenCode 2 diagnostics conservative: help advertises only implemented V2 behavior, doctor reports local runtime/state information, and full stable-plugin parity remains explicitly unclaimed.
8
+ - Expand V2 lifecycle, real `@next` command-registry, general CI, and generated-bundle gates around the new diagnostics and release-safety contracts.
9
+
10
+ ## 0.5.29
11
+
12
+ - Fix `--no-now` first-run semantics end to end: delayed jobs now wait until `createdAt + interval`, and stable `/loop-status` reports that first due time correctly instead of showing the job as immediately due.
13
+ - Add experimental OpenCode 2 parity for native `/loop-status` and target-specific `/loop-now`, including real host command registration, idle-safe run-now persistence, target priority, `noReply` status output, and deterministic lifecycle coverage.
14
+ - Make stable `/loop-now <target>` truly target-specific across busy retries and avoid unrelated due jobs; persist a one-shot run-now marker, prioritize the requested job, and consume it only when dispatch begins.
15
+ - Defer stable `/loop-now` execution to the idle-safe scheduler instead of dispatching re-entrantly from `command.execute.before`, fixing a Windows real-host race where the command acknowledgement turn could supersede the requested autonomous prompt.
16
+ - Strengthen release confidence with Ubuntu/Windows real-host target canaries, generated-bundle synchronization, Bundle Gate coverage for host-canary changes, and bounded scheduler-state waits in comprehensive Windows regressions.
17
+
18
+ ## 0.5.28
19
+
20
+ - Preserve experimental `/loop-goal` across ordinary queued user steering: preempt only the in-flight Goal turn, run the foreground user turn first, keep the Goal active/unpaused, then resume autonomous Goal work. Gate heartbeat, due, and watchdog dispatch while steering is pending, reject stale Goal lifecycle mutations, and cover the behavior with real OpenCode host canaries on Ubuntu and Windows for both source and freshly rebuilt bundles.
21
+ - Complete the planned V1 modularization by extracting command routing/handlers, Loop registration, session activity/status, scheduling, Goal runtime/policy/steering, job workspace, compaction, and executor responsibilities into focused testable modules while keeping `legacy-v1.js` as the composition root.
22
+ - Expand the experimental OpenCode 2 runtime with idle-safe interval prompt loops, Loop control commands, ordinary slash-command transport, lifecycle cleanup, and real-host adapter/activation contracts. Correct V2 command transport to use the host `command` field rather than the optional message `id` field. Stable package compatibility remains `@opencode-ai/plugin >=1.4.0 <2`; this release does not claim stable OpenCode 2 parity.
23
+ - Harden the Goal steering host canary cold bootstrap from 45 seconds to a bounded 90 seconds and include the OpenCode server log when bootstrap still fails, without weakening the steering assertions.
24
+
3
25
  ## 0.5.27
4
26
 
5
27
  - Rebase stale Loop state snapshots against the latest persisted job state before writing, preventing scheduler bookkeeping from erasing unrelated user pause/resume or other state changes.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, safe long-running continuation helpers, and the `opencode-loopd` background daemon.
6
6
 
7
- > **Current release: `0.5.27`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
7
+ > **Current release: `0.5.30`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
8
8
 
9
9
  ## Install or update
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.28",
3
+ "version": "0.5.30",
4
4
  "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -21,9 +21,9 @@
21
21
  "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
22
22
  "build:plugin:npm": "npm run build:plugin",
23
23
  "prepack": "node --check src/index.js",
24
- "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs",
25
- "test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
26
- "canary:host": "node scripts/host-loop-canary.mjs",
24
+ "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
25
+ "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
26
+ "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
27
27
  "install:global": "node scripts/install-node.mjs",
28
28
  "pack:zip": "node scripts/make-zip.mjs"
29
29
  },
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict"
2
+ import {
3
+ clearCommandLifecycle,
4
+ commandArgsText,
5
+ commandName,
6
+ consumeHandled,
7
+ forgetHandledCommandEvent,
8
+ hasHandledCommandEvent,
9
+ isLoopCommandName,
10
+ isPreset,
11
+ markHandled,
12
+ markHandledCommandEvent,
13
+ } from "../src/source/opencode/commands.js"
14
+
15
+ assert.equal(commandName(undefined), "")
16
+ assert.equal(commandName(42), "42")
17
+ assert.equal(isPreset("loop-dev"), true)
18
+ assert.equal(isPreset("loop-status"), false)
19
+ assert.equal(isLoopCommandName("loop-status"), true)
20
+ assert.equal(isLoopCommandName("loop-goal-complete"), true)
21
+ assert.equal(isLoopCommandName("other-command"), false)
22
+ assert.equal(commandArgsText(["one", { text: "two" }, 3]), "one two 3")
23
+ assert.equal(commandArgsText({ arguments: { value: "nested" } }), "nested")
24
+
25
+ markHandled("session-a", "loop-status", " repeated args ")
26
+ assert.equal(consumeHandled("session-a", "loop-status", "repeated args"), true)
27
+ assert.equal(consumeHandled("session-a", "loop-status", "repeated args"), false)
28
+
29
+ markHandled("session-b", "loop-now", "job")
30
+ markHandled("session-b", "loop-now", "job")
31
+ assert.equal(consumeHandled("session-b", "loop-now", "job"), true)
32
+ assert.equal(consumeHandled("session-b", "loop-now", "job"), true)
33
+ assert.equal(consumeHandled("session-b", "loop-now", "job"), false)
34
+
35
+ assert.equal(hasHandledCommandEvent("session-c", "message-1"), false)
36
+ markHandledCommandEvent("session-c", "message-1")
37
+ assert.equal(hasHandledCommandEvent("session-c", "message-1"), true)
38
+ forgetHandledCommandEvent("session-c", "message-1")
39
+ assert.equal(hasHandledCommandEvent("session-c", "message-1"), false)
40
+
41
+ markHandled("session-d", "loop-status", "")
42
+ markHandledCommandEvent("session-d", "message-2")
43
+ clearCommandLifecycle("session-d")
44
+ assert.equal(consumeHandled("session-d", "loop-status", ""), false)
45
+ assert.equal(hasHandledCommandEvent("session-d", "message-2"), false)
46
+
47
+ console.log("OpenCode command lifecycle test passed")
@@ -0,0 +1,175 @@
1
+ import assert from "node:assert/strict"
2
+ import { createCommandRouter } from "../src/source/opencode/command-router.js"
3
+ import { clearCommandLifecycle } from "../src/source/opencode/commands.js"
4
+
5
+ const HANDLER_NAMES = [
6
+ "addGoal",
7
+ "statusGoal",
8
+ "pauseGoal",
9
+ "resumeGoal",
10
+ "clearGoal",
11
+ "completeGoalCommand",
12
+ "blockGoalCommand",
13
+ "addLoop",
14
+ "stopLoop",
15
+ "statusLoop",
16
+ "logsLoop",
17
+ "helpLoop",
18
+ "runNow",
19
+ "updateJobState",
20
+ "doctorLoop",
21
+ "initLoop",
22
+ "exportLoop",
23
+ ]
24
+
25
+ function harness() {
26
+ const calls = []
27
+ const remembered = []
28
+ const captured = []
29
+ const guarded = []
30
+ const handlers = Object.fromEntries(HANDLER_NAMES.map((name) => [name, async (...args) => {
31
+ calls.push({ name, args })
32
+ }]))
33
+ const router = createCommandRouter({
34
+ rememberSession: (...args) => remembered.push(args),
35
+ captureSessionExecutionContext: async (...args) => { captured.push(args) },
36
+ guardLoopOwnedUserMessage: (...args) => guarded.push(args),
37
+ handlers,
38
+ })
39
+ return { router, calls, remembered, captured, guarded }
40
+ }
41
+
42
+ assert.throws(() => createCommandRouter({}), /rememberSession/)
43
+ assert.throws(() => createCommandRouter({ rememberSession() {}, handlers: {} }), /handlers\.addGoal/)
44
+
45
+ {
46
+ const sessionID = "router-before"
47
+ clearCommandLifecycle(sessionID)
48
+ const h = harness()
49
+ const output = {}
50
+ const client = { id: "client" }
51
+ const result = await h.router("/work", client, { sessionID, command: "loop-status", arguments: "" }, undefined, undefined, output)
52
+ assert.equal(result, true)
53
+ assert.equal(output.noReply, true)
54
+ assert.deepEqual(h.calls.map((item) => item.name), ["statusLoop"])
55
+ assert.deepEqual(h.calls[0].args, ["/work", client, sessionID])
56
+ assert.equal(h.remembered.length, 1)
57
+ assert.deepEqual(h.remembered[0], ["/work", client, sessionID])
58
+ assert.equal(h.captured.length, 1)
59
+ assert.deepEqual(h.captured[0], [client, sessionID])
60
+ assert.deepEqual(h.guarded, [[sessionID]])
61
+
62
+ const eventResult = await h.router("/work", client, { sessionID, name: "loop-status", arguments: "", messageID: "msg-1" }, undefined, undefined, undefined, "event")
63
+ assert.equal(eventResult, true)
64
+ assert.equal(h.calls.length, 1, "matching command.executed event must not run the handler twice")
65
+ assert.equal(h.remembered.length, 2)
66
+ assert.equal(h.captured.length, 2)
67
+ assert.equal(h.guarded.length, 1, "consumed compatibility event must return before user-message guarding")
68
+ clearCommandLifecycle(sessionID)
69
+ }
70
+
71
+ {
72
+ const sessionID = "router-event"
73
+ clearCommandLifecycle(sessionID)
74
+ const h = harness()
75
+ const client = {}
76
+ const event = { sessionID, name: "loop-help", arguments: "", messageID: "evt-1" }
77
+ assert.equal(await h.router("/work", client, event, undefined, undefined, undefined, "event"), true)
78
+ assert.deepEqual(h.calls.map((item) => item.name), ["helpLoop"])
79
+ assert.deepEqual(h.calls[0].args, [client, sessionID])
80
+ assert.deepEqual(h.guarded, [[sessionID]])
81
+
82
+ assert.equal(await h.router("/work", client, event, undefined, undefined, undefined, "event"), true)
83
+ assert.equal(h.calls.length, 1, "same event message ID must be idempotent")
84
+ assert.equal(h.guarded.length, 1)
85
+ clearCommandLifecycle(sessionID)
86
+ }
87
+
88
+ {
89
+ const sessionID = "router-preset"
90
+ clearCommandLifecycle(sessionID)
91
+ const h = harness()
92
+ assert.equal(await h.router("/repo", {}, { sessionID, command: "loop-shell", arguments: "10m npm test" }), true)
93
+ assert.equal(h.calls.length, 1)
94
+ assert.equal(h.calls[0].name, "addLoop")
95
+ assert.equal(h.calls[0].args[0], "/repo")
96
+ assert.equal(h.calls[0].args[2], sessionID)
97
+ assert.equal(h.calls[0].args[3], "10m npm test")
98
+ assert.equal(h.calls[0].args[4].kind, "shell")
99
+ assert.equal(h.calls[0].args[4].name, "shell")
100
+ clearCommandLifecycle(sessionID)
101
+ }
102
+
103
+ {
104
+ const sessionID = "router-state"
105
+ clearCommandLifecycle(sessionID)
106
+ const h = harness()
107
+ assert.equal(await h.router("/work", {}, { sessionID, command: "loop-pause", arguments: "dev" }), true)
108
+ let call = h.calls.at(-1)
109
+ assert.equal(call.name, "updateJobState")
110
+ assert.equal(call.args[3], "dev")
111
+ assert.equal(call.args[5], "Paused")
112
+ assert.deepEqual(call.args[4]({ paused: false, lastRunAt: 99, keep: true }), { paused: true, lastRunAt: 99, keep: true })
113
+
114
+ assert.equal(await h.router("/work", {}, { sessionID, command: "loop-resume", arguments: "dev" }), true)
115
+ call = h.calls.at(-1)
116
+ assert.equal(call.name, "updateJobState")
117
+ assert.equal(call.args[5], "Resumed")
118
+ assert.deepEqual(call.args[4]({ paused: true, lastRunAt: 99, keep: true }), { paused: false, lastRunAt: 0, keep: true })
119
+
120
+ assert.equal(await h.router("/work", {}, { sessionID, command: "loop-clear", arguments: "ignored" }), true)
121
+ call = h.calls.at(-1)
122
+ assert.equal(call.name, "stopLoop")
123
+ assert.equal(call.args[3], "all")
124
+ clearCommandLifecycle(sessionID)
125
+ }
126
+
127
+ {
128
+ const sessionID = "router-aliases"
129
+ clearCommandLifecycle(sessionID)
130
+ const h = harness()
131
+ assert.equal(await h.router("/work", {}, { sessionID, command: "loop-goal-complete", arguments: "ship it" }), true)
132
+ assert.equal(h.calls.at(-1).name, "completeGoalCommand")
133
+ assert.equal(h.calls.at(-1).args[3], "ship it")
134
+ assert.equal(await h.router("/work", {}, { sessionID, command: "loop-remove", arguments: "dev" }), true)
135
+ assert.equal(h.calls.at(-1).name, "stopLoop")
136
+ assert.equal(h.calls.at(-1).args[3], "dev")
137
+ clearCommandLifecycle(sessionID)
138
+ }
139
+
140
+ {
141
+ const sessionID = "router-unknown-before"
142
+ clearCommandLifecycle(sessionID)
143
+ const h = harness()
144
+ const output = {}
145
+ assert.equal(await h.router("/work", {}, { sessionID, command: "not-loop", arguments: "x" }, undefined, undefined, output), false)
146
+ assert.equal(await h.router("/work", {}, { sessionID, command: "not-loop", arguments: "x" }, undefined, undefined, output), false)
147
+ assert.equal(h.calls.length, 0)
148
+ assert.equal(h.captured.length, 0)
149
+ assert.equal(h.guarded.length, 0)
150
+ assert.equal(output.noReply, undefined)
151
+ clearCommandLifecycle(sessionID)
152
+ }
153
+
154
+ {
155
+ const sessionID = "router-unknown-event"
156
+ clearCommandLifecycle(sessionID)
157
+ const h = harness()
158
+ const event = { sessionID, name: "not-loop", arguments: "x", messageID: "unknown-1" }
159
+ assert.equal(await h.router("/work", {}, event, undefined, undefined, undefined, "event"), false)
160
+ assert.equal(await h.router("/work", {}, event, undefined, undefined, undefined, "event"), false)
161
+ assert.equal(h.calls.length, 0)
162
+ assert.equal(h.captured.length, 0)
163
+ assert.equal(h.guarded.length, 0)
164
+ clearCommandLifecycle(sessionID)
165
+ }
166
+
167
+ {
168
+ const h = harness()
169
+ assert.equal(await h.router("/work", {}, { command: "loop-status" }), false)
170
+ assert.equal(await h.router("/work", {}, { sessionID: "missing-name" }), false)
171
+ assert.equal(h.remembered.length, 0)
172
+ assert.equal(h.calls.length, 0)
173
+ }
174
+
175
+ console.log("command router tests passed")
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert/strict"
2
+ import { createCompactionRuntime } from "../src/source/runtime/compaction.js"
3
+
4
+ assert.throws(() => createCompactionRuntime({}), /activeRuns Map/)
5
+ assert.throws(() => createCompactionRuntime({ activeRuns: new Map() }), /finalizeActiveRun/)
6
+
7
+ let clock = 10_000
8
+ const activeRuns = new Map()
9
+ const compactCalls = []
10
+ const compactResults = []
11
+ const logs = []
12
+ const hostLogs = []
13
+ const finalizeCalls = []
14
+
15
+ const runtime = createCompactionRuntime({
16
+ activeRuns,
17
+ now: () => clock,
18
+ compactSession: async (...args) => {
19
+ compactCalls.push(args)
20
+ return compactResults.shift() ?? true
21
+ },
22
+ appendLoopLog: async (...args) => { logs.push(args) },
23
+ finalizeActiveRun: async (...args) => {
24
+ finalizeCalls.push(args)
25
+ return true
26
+ },
27
+ log: async (...args) => { hostLogs.push(args) },
28
+ errorMessage: (error) => `ERR:${error?.message || error}`,
29
+ })
30
+
31
+ assert.equal(runtime.getPending("s"), undefined)
32
+ assert.equal(runtime.begin(undefined, "job"), undefined)
33
+ assert.equal(runtime.begin("s", undefined), undefined)
34
+
35
+ const first = runtime.begin("s", "job-1", true)
36
+ assert.deepEqual(first, {
37
+ jobId: "job-1",
38
+ resumeAfter: true,
39
+ requestedAt: 10_000,
40
+ startedAt: 0,
41
+ completedAt: 0,
42
+ })
43
+ assert.equal(runtime.getPending("s"), first)
44
+ assert.equal(runtime.isCompleted("s", "job-1"), false)
45
+ assert.equal(runtime.clear("s"), true)
46
+ assert.equal(runtime.getPending("s"), undefined)
47
+
48
+ runtime.begin("s", "job-1")
49
+ runtime.clearForActiveRun("s", { jobId: "other" })
50
+ assert.equal(runtime.getPending("s")?.jobId, "job-1")
51
+ runtime.clearForActiveRun("s", { jobId: "job-1" })
52
+ assert.equal(runtime.getPending("s"), undefined)
53
+ runtime.begin("s", "job-1")
54
+ runtime.clearForActiveRun("s", undefined)
55
+ assert.equal(runtime.getPending("s"), undefined)
56
+
57
+ const idleJob = { id: "idle", compactEveryRuns: 0, compactEveryMs: 0, runCount: 0 }
58
+ assert.deepEqual(await runtime.maybeCompact("/repo", {}, "s", idleJob), { job: idleJob, started: false })
59
+ assert.equal(compactCalls.length, 0)
60
+
61
+ const runJob = { id: "runs", compactEveryRuns: 2, compactEveryMs: 0, runCount: 2, lastCompactRunCount: 0, model: "m" }
62
+ compactResults.push(true)
63
+ clock = 20_000
64
+ const runResult = await runtime.maybeCompact("/repo", { id: "client" }, "s", runJob)
65
+ assert.equal(runResult.started, true)
66
+ assert.equal(runJob.lastCompactAt, 20_000)
67
+ assert.equal(runJob.lastCompactRunCount, 2)
68
+ assert.equal(runtime.getPending("s")?.resumeAfter, true)
69
+ assert.deepEqual(compactCalls.at(-1), ["/repo", { id: "client" }, "s", "m"])
70
+
71
+ const beforeStartLogs = logs.length
72
+ clock = 21_000
73
+ assert.equal(await runtime.noteStarted("/repo", "s"), true)
74
+ assert.equal(runtime.getPending("s").startedAt, 21_000)
75
+ assert.deepEqual(logs.at(-1), ["/repo", "compact-started", { sessionID: "s", job: "runs", resumeAfter: true }])
76
+ assert.equal(await runtime.noteStarted("/repo", "s"), true)
77
+ assert.equal(logs.length, beforeStartLogs + 1)
78
+
79
+ activeRuns.set("s", { jobId: "other" })
80
+ assert.equal(await runtime.finalize("/repo", {}, "s"), false)
81
+ activeRuns.set("s", { jobId: "runs" })
82
+ assert.equal(await runtime.finalize("/repo", { id: "client" }, "s"), true)
83
+ assert.deepEqual(finalizeCalls.at(-1), ["/repo", { id: "client" }, "s"])
84
+
85
+ clock = 22_000
86
+ assert.equal(await runtime.noteCompleted("/repo", { id: "client" }, "s"), true)
87
+ assert.equal(runtime.getPending("s").completedAt, 22_000)
88
+ assert.equal(runtime.isCompleted("s", "runs"), true)
89
+ assert.deepEqual(logs.at(-1), ["/repo", "compact-event", { sessionID: "s", job: "runs", resumeAfter: true }])
90
+ await new Promise((resolve) => setTimeout(resolve, 20))
91
+ assert.ok(finalizeCalls.length >= 2)
92
+
93
+ runtime.clear("s")
94
+ assert.equal(await runtime.noteStarted("/repo", "s"), false)
95
+ assert.equal(await runtime.noteCompleted("/repo", {}, "s"), false)
96
+ assert.equal(await runtime.finalize("/repo", {}, "s"), false)
97
+
98
+ const failedJob = { id: "failed", compactEveryRuns: 1, compactEveryMs: 0, runCount: 1, model: undefined }
99
+ compactResults.push(false)
100
+ clock = 30_000
101
+ const failedResult = await runtime.maybeCompact("/repo", {}, "failed-session", failedJob)
102
+ assert.equal(failedResult.started, false)
103
+ assert.equal(runtime.getPending("failed-session"), undefined)
104
+ assert.equal(failedJob.lastCompactAt, undefined)
105
+
106
+ const timeJob = { id: "time", compactEveryRuns: 0, compactEveryMs: 500, runCount: 7, lastCompactAt: 39_000 }
107
+ compactResults.push(true)
108
+ clock = 40_000
109
+ assert.equal((await runtime.maybeCompact("/repo", {}, "time-session", timeJob)).started, true)
110
+ assert.equal(timeJob.lastCompactAt, 40_000)
111
+ assert.equal(timeJob.lastCompactRunCount, 7)
112
+
113
+ const errorActiveRuns = new Map([["err", { jobId: "error-job" }]])
114
+ const errorLogs = []
115
+ const errorRuntime = createCompactionRuntime({
116
+ activeRuns: errorActiveRuns,
117
+ now: () => 50_000,
118
+ appendLoopLog: async () => {},
119
+ finalizeActiveRun: async () => { throw new Error("boom") },
120
+ log: async (...args) => { errorLogs.push(args) },
121
+ errorMessage: (error) => `wrapped:${error.message}`,
122
+ })
123
+ errorRuntime.begin("err", "error-job")
124
+ await errorRuntime.noteCompleted("/repo", { id: "client" }, "err")
125
+ await new Promise((resolve) => setTimeout(resolve, 20))
126
+ assert.deepEqual(errorLogs.at(-1), [{ id: "client" }, "error", "compaction finalization failed", { error: "wrapped:boom" }])
127
+
128
+ console.log("compaction runtime tests passed")
@@ -138,6 +138,8 @@ async function createHarness(options = {}) {
138
138
  messageHistory,
139
139
  async command(command, argumentsText = "", output = { parts: [] }) {
140
140
  await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
141
+ // /loop-now persists a request and dispatches through the scheduler's 250ms idle-safe timer.
142
+ if (command === "loop-now") await delay(400)
141
143
  return output
142
144
  },
143
145
  async commandEvent(command, argumentsText = "", messageID = `msg_${Date.now()}_${Math.random()}`) {
@@ -356,9 +358,12 @@ async function testActionRoutingAndSafety() {
356
358
  await h.command("loop-clear")
357
359
  await h.command("loop-shell", "0s --safe rm -r -f ./important")
358
360
  await h.command("loop-now", "shell")
359
- const separateFlagsState = await h.readState()
361
+ const separateFlagsState = await waitForValue(async () => {
362
+ const candidate = await h.readState()
363
+ return candidate.jobs[0]?.lastFailureReason === "safe_shell_blocked" ? candidate : undefined
364
+ }, 2_000)
360
365
  assert.equal(h.records.shells.length, 0)
361
- assert.equal(separateFlagsState.jobs[0].lastFailureReason, "safe_shell_blocked", "separate rm -r -f flags must be blocked")
366
+ assert.equal(separateFlagsState?.jobs[0]?.lastFailureReason, "safe_shell_blocked", "separate rm -r -f flags must be blocked")
362
367
  } finally {
363
368
  await h.cleanup()
364
369
  }
@@ -477,6 +482,9 @@ async function testStaleBusyUsesCompletedAssistantTail() {
477
482
  { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },
478
483
  { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },
479
484
  )
485
+ // Let the short busy-status cache expire so the scheduler re-reads live host state
486
+ // and can reconcile the completed assistant tail instead of trusting cached busy.
487
+ await delay(1_700)
480
488
  await h.command("loop-now", "stale-complete")
481
489
  const state = await h.readState()
482
490
  assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
@@ -612,7 +620,7 @@ async function testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt() {
612
620
  properties: { info: { id: "msg_real_user", sessionID: h.sessionID, role: "user" } },
613
621
  } })
614
622
  state = await h.readState()
615
- assert.equal(state.jobs[0].paused, true, "a distinct real user message must still pause an active goal")
623
+ assert.equal(state.jobs[0].paused, false, "a distinct real user message must steer an active goal without pausing it")
616
624
  } finally {
617
625
  Date.now = originalDateNow
618
626
  await h.cleanup()