@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +7 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +29 -7
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +68 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +82 -0
- package/scripts/v2-plugin-sentinel-test.mjs +136 -0
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +3737 -2220
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +252 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +71 -0
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +174 -0
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +47 -0
- package/src/source/opencode2/host-contract.js +94 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/session-status.js +197 -0
- package/src/source/runtime/timers.js +45 -0
- 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.
|
|
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.
|
|
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
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
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
|
|
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
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
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,
|
|
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
|
+
}
|