@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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/package.json +4 -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 +11 -3
- 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-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -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-loop-canary.mjs +433 -0
- package/scripts/publish-workflow-test.mjs +25 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -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-diagnostics-test.mjs +74 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +60 -8
- 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 +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- 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/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/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +72 -0
- package/src/source/opencode2/diagnostics.js +80 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +41 -1
- package/src/source/opencode2/host-contract.js +26 -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/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/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- 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.
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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()
|