@bybrawe/opencode-loop 0.5.27 → 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 (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -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 +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. package/src/source/v1.js +69 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.29
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+
11
+ ## 0.5.28
12
+
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+
3
18
  ## 0.5.27
4
19
 
5
20
  - 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.29`.** 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.27",
3
+ "version": "0.5.29",
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",
@@ -18,9 +18,12 @@
18
18
  "CHANGELOG.md"
19
19
  ],
20
20
  "scripts": {
21
- "check": "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/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs",
22
- "test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/comprehensive-test.mjs",
23
- "canary:host": "node scripts/host-loop-canary.mjs",
21
+ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
22
+ "build:plugin:npm": "npm run build:plugin",
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/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/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/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs",
25
+ "test": "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/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",
24
27
  "install:global": "node scripts/install-node.mjs",
25
28
  "pack:zip": "node scripts/make-zip.mjs"
26
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")
@@ -6,6 +6,18 @@ import path from "node:path"
6
6
  import OpenCodeLoopPlugin from "../src/index.js"
7
7
 
8
8
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
9
+
10
+ async function waitForValue(read, timeoutMs = 1_000, intervalMs = 10) {
11
+ const deadline = Date.now() + timeoutMs
12
+ let value
13
+ while (Date.now() < deadline) {
14
+ value = await read()
15
+ if (value) return value
16
+ await delay(intervalMs)
17
+ }
18
+ return await read()
19
+ }
20
+
9
21
  let harnessNumber = 0
10
22
 
11
23
  async function createHarness(options = {}) {
@@ -126,6 +138,8 @@ async function createHarness(options = {}) {
126
138
  messageHistory,
127
139
  async command(command, argumentsText = "", output = { parts: [] }) {
128
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)
129
143
  return output
130
144
  },
131
145
  async commandEvent(command, argumentsText = "", messageID = `msg_${Date.now()}_${Math.random()}`) {
@@ -344,9 +358,12 @@ async function testActionRoutingAndSafety() {
344
358
  await h.command("loop-clear")
345
359
  await h.command("loop-shell", "0s --safe rm -r -f ./important")
346
360
  await h.command("loop-now", "shell")
347
- 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)
348
365
  assert.equal(h.records.shells.length, 0)
349
- 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")
350
367
  } finally {
351
368
  await h.cleanup()
352
369
  }
@@ -405,9 +422,11 @@ async function testNativeCompactionLifecycleAndFallback() {
405
422
  await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput)
406
423
  assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt")
407
424
  await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
408
- await delay(20)
409
- const state = await h.readState()
410
- assert.ok(state.jobs[0].lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
425
+ const state = await waitForValue(async () => {
426
+ const candidate = await h.readState()
427
+ return candidate.jobs[0]?.lastFinishedAt > 0 ? candidate : undefined
428
+ })
429
+ assert.ok(state?.jobs[0]?.lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
411
430
  } finally {
412
431
  await h.cleanup()
413
432
  }
@@ -463,6 +482,9 @@ async function testStaleBusyUsesCompletedAssistantTail() {
463
482
  { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },
464
483
  { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },
465
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)
466
488
  await h.command("loop-now", "stale-complete")
467
489
  const state = await h.readState()
468
490
  assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
@@ -598,7 +620,7 @@ async function testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt() {
598
620
  properties: { info: { id: "msg_real_user", sessionID: h.sessionID, role: "user" } },
599
621
  } })
600
622
  state = await h.readState()
601
- 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")
602
624
  } finally {
603
625
  Date.now = originalDateNow
604
626
  await h.cleanup()
@@ -750,4 +772,4 @@ await testWindowsStateRenameRetriesBeforeFallback()
750
772
  await testStateReadRetriesTransientPartialJson()
751
773
  await testStateRebasePreservesConcurrentPause()
752
774
 
753
- console.log("OpenCode Loop comprehensive test passed")
775
+ console.log("OpenCode Loop comprehensive test passed")
@@ -0,0 +1,32 @@
1
+ import { promises as fs } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
+
6
+ const scriptsDir = path.dirname(fileURLToPath(import.meta.url))
7
+ const rootDir = path.resolve(scriptsDir, "..")
8
+ const sourcePath = path.join(scriptsDir, "comprehensive-test.mjs")
9
+ const timeoutMs = Math.max(5_000, Number(process.env.OPENCODE_LOOP_COMPREHENSIVE_TIMEOUT_MS) || 60_000)
10
+ const tempPath = path.join(os.tmpdir(), `opencode-loop-comprehensive-watchdog-${process.pid}-${Date.now()}.mjs`)
11
+
12
+ const source = await fs.readFile(sourcePath, "utf8")
13
+ const pluginURL = pathToFileURL(path.join(rootDir, "src", "index.js")).href
14
+ let instrumented = source.replace('import OpenCodeLoopPlugin from "../src/index.js"', `import OpenCodeLoopPlugin from ${JSON.stringify(pluginURL)}`)
15
+ instrumented = instrumented.replace(
16
+ /^await (test[A-Za-z0-9_]+)\(\)$/gm,
17
+ 'globalThis.__opencodeLoopActiveCase = "$1"; await $1(); globalThis.__opencodeLoopActiveCase = undefined',
18
+ )
19
+ if (instrumented === source) throw new Error("failed to instrument comprehensive test")
20
+
21
+ await fs.writeFile(tempPath, instrumented, "utf8")
22
+ const watchdog = setTimeout(() => {
23
+ const active = globalThis.__opencodeLoopActiveCase
24
+ throw new Error(`OpenCode Loop comprehensive test timed out after ${timeoutMs}ms${active ? ` while running ${active}` : ""}`)
25
+ }, timeoutMs)
26
+
27
+ try {
28
+ await import(`${pathToFileURL(tempPath).href}?run=${Date.now()}`)
29
+ } finally {
30
+ clearTimeout(watchdog)
31
+ try { await fs.rm(tempPath, { force: true }) } catch {}
32
+ }