@bybrawe/opencode-loop 0.5.32 → 0.5.33
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 +7 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/scripts/action-dispatch-test.mjs +117 -0
- package/scripts/run-admission-test.mjs +133 -0
- package/scripts/run-finalization-test.mjs +119 -0
- package/src/index.js +479 -369
- package/src/source/runtime/action-dispatch.js +108 -0
- package/src/source/runtime/executor.js +55 -228
- package/src/source/runtime/goal-evidence.js +39 -0
- package/src/source/runtime/goal-policy.js +1 -1
- package/src/source/runtime/goal-prompt.js +53 -0
- package/src/source/runtime/goal-report.js +52 -0
- package/src/source/runtime/goal-runtime.js +9 -141
- package/src/source/runtime/run-admission.js +132 -0
- package/src/source/runtime/run-finalization.js +100 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.33
|
|
4
|
+
|
|
5
|
+
- Complete the post-0.5.32 stable V1 source decomposition by extracting action dispatch, run admission, and post-turn finalization from the executor into focused runtime modules while preserving scheduler behavior and dependency-injection compatibility.
|
|
6
|
+
- Split experimental Loop Goal prompt rendering, report writing, and evidence/progress decision helpers into dedicated modules while preserving the existing source facade and runtime state schema.
|
|
7
|
+
- Regenerate the committed single-file stable plugin from the modular source tree and keep Linux/Windows real-host, compatibility, generated-bundle, and lifecycle gates green throughout the refactor.
|
|
8
|
+
- Add direct regression coverage for the extracted admission/finalization paths; this release is intended to be behavior-preserving with no user-visible command changes.
|
|
9
|
+
|
|
3
10
|
## 0.5.32
|
|
4
11
|
|
|
5
12
|
- Hide the tool-denied `opencode-loop-local` acknowledgement agent from the normal primary-agent cycle by installing it as a hidden subagent while keeping Loop slash commands explicitly bound to it.
|
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.33`.** 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.33",
|
|
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,8 +21,8 @@
|
|
|
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/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/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-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/v2-logging-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/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.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/action-dispatch.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.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/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-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/v2-logging-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/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-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/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-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
26
|
"canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
|
|
27
27
|
"install:global": "node scripts/install-with-goals.mjs",
|
|
28
28
|
"pack:zip": "node scripts/make-zip.mjs"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createActionDispatcher } from "../src/source/runtime/action-dispatch.js"
|
|
3
|
+
|
|
4
|
+
assert.throws(() => createActionDispatcher({}), /buildPrompt/)
|
|
5
|
+
assert.throws(() => createActionDispatcher({ buildPrompt: async () => "prompt" }), /compactionRuntime\.start/)
|
|
6
|
+
|
|
7
|
+
const sdkCalls = []
|
|
8
|
+
const fireCalls = []
|
|
9
|
+
const compactions = []
|
|
10
|
+
const guards = []
|
|
11
|
+
const logs = []
|
|
12
|
+
const toasts = []
|
|
13
|
+
const prompts = []
|
|
14
|
+
|
|
15
|
+
const dispatcher = createActionDispatcher({
|
|
16
|
+
buildPrompt: async (_directory, job) => {
|
|
17
|
+
prompts.push(job.id)
|
|
18
|
+
return `BUILT:${job.action}`
|
|
19
|
+
},
|
|
20
|
+
compactionRuntime: {
|
|
21
|
+
start: async (...args) => {
|
|
22
|
+
compactions.push(args)
|
|
23
|
+
return true
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
appendLoopLog: async (...args) => { logs.push(args) },
|
|
27
|
+
sdkCall: async (...args) => { sdkCalls.push(args); return {} },
|
|
28
|
+
normalizedModelRef: (value) => value ? { providerID: "provider", modelID: String(value) } : undefined,
|
|
29
|
+
fireSdk: (...args) => { fireCalls.push(args); return Promise.resolve({ ok: true }) },
|
|
30
|
+
compactTuiCommandName: (command) => command === "compact" ? "compact" : undefined,
|
|
31
|
+
toast: async (...args) => { toasts.push(args) },
|
|
32
|
+
guardLoopOwnedUserMessage: (sessionID) => { guards.push(sessionID) },
|
|
33
|
+
dangerousShell: (command) => /danger/.test(command),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const client = {
|
|
37
|
+
session: {
|
|
38
|
+
command: async () => ({ data: {} }),
|
|
39
|
+
shell: async () => ({ data: {} }),
|
|
40
|
+
prompt: async () => ({ data: {} }),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const blockedShell = await dispatcher.fireAction("/repo", client, "shell-session", {
|
|
45
|
+
id: "shell-job",
|
|
46
|
+
action: "! danger --all",
|
|
47
|
+
safe: true,
|
|
48
|
+
})
|
|
49
|
+
assert.deepEqual(blockedShell, { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" })
|
|
50
|
+
assert.equal(fireCalls.length, 0)
|
|
51
|
+
assert.equal(logs.at(-1)[1], "blocked")
|
|
52
|
+
assert.match(toasts.at(-1)[1], /Blocked dangerous shell command/)
|
|
53
|
+
|
|
54
|
+
const commandResult = await dispatcher.fireAction("/repo", client, "command-session", {
|
|
55
|
+
id: "command-job",
|
|
56
|
+
action: "/review --quick",
|
|
57
|
+
agent: "build",
|
|
58
|
+
model: "model-a",
|
|
59
|
+
})
|
|
60
|
+
assert.deepEqual(commandResult, { startsAssistantTurn: true })
|
|
61
|
+
assert.equal(sdkCalls.length, 1)
|
|
62
|
+
assert.equal(guards.at(-1), "command-session")
|
|
63
|
+
assert.deepEqual(sdkCalls.at(-1)[1].body, {
|
|
64
|
+
command: "review",
|
|
65
|
+
arguments: "--quick",
|
|
66
|
+
agent: "build",
|
|
67
|
+
model: "provider/model-a",
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const compactResult = await dispatcher.fireAction("/repo", client, "compact-session", {
|
|
71
|
+
id: "compact-job",
|
|
72
|
+
action: "/compact",
|
|
73
|
+
agent: "build",
|
|
74
|
+
model: "model-b",
|
|
75
|
+
})
|
|
76
|
+
assert.deepEqual(compactResult, { startsAssistantTurn: true, pause: false, reason: "compact_failed", compaction: true })
|
|
77
|
+
assert.equal(compactions.length, 1)
|
|
78
|
+
assert.equal(compactions.at(-1)[2], "compact-session")
|
|
79
|
+
assert.equal(compactions.at(-1)[3], "compact-job")
|
|
80
|
+
assert.deepEqual(compactions.at(-1)[4], { providerID: "provider", modelID: "model-b" })
|
|
81
|
+
|
|
82
|
+
const shellResult = await dispatcher.fireAction("/repo", client, "safe-shell-session", {
|
|
83
|
+
id: "safe-shell-job",
|
|
84
|
+
action: "! echo ok",
|
|
85
|
+
safe: true,
|
|
86
|
+
model: "model-c",
|
|
87
|
+
})
|
|
88
|
+
assert.equal(shellResult.startsAssistantTurn, true)
|
|
89
|
+
assert.ok(shellResult.dispatch instanceof Promise)
|
|
90
|
+
assert.equal(fireCalls.at(-1)[1], "session.shell")
|
|
91
|
+
assert.deepEqual(fireCalls.at(-1)[3].body, {
|
|
92
|
+
command: "echo ok",
|
|
93
|
+
agent: "build",
|
|
94
|
+
model: { providerID: "provider", modelID: "model-c" },
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const promptResult = await dispatcher.fireAction("/repo", client, "prompt-session", {
|
|
98
|
+
id: "prompt-job",
|
|
99
|
+
action: "keep working",
|
|
100
|
+
agent: "build",
|
|
101
|
+
})
|
|
102
|
+
assert.equal(promptResult.startsAssistantTurn, true)
|
|
103
|
+
assert.ok(promptResult.dispatch instanceof Promise)
|
|
104
|
+
assert.equal(prompts.at(-1), "prompt-job")
|
|
105
|
+
assert.equal(fireCalls.at(-1)[1], "session.prompt")
|
|
106
|
+
assert.match(fireCalls.at(-1)[3].body.parts[0].text, /AUTONOMOUS OPENCODE LOOP ITERATION/)
|
|
107
|
+
assert.match(fireCalls.at(-1)[3].body.parts[0].text, /BUILT:keep working/)
|
|
108
|
+
|
|
109
|
+
const goalPromptResult = await dispatcher.fireAction("/repo", client, "goal-session", {
|
|
110
|
+
id: "goal-job",
|
|
111
|
+
kind: "goal",
|
|
112
|
+
action: "finish objective",
|
|
113
|
+
})
|
|
114
|
+
assert.equal(goalPromptResult.startsAssistantTurn, true)
|
|
115
|
+
assert.match(fireCalls.at(-1)[3].body.parts[0].text, /EXPERIMENTAL GOAL MODE CONTINUATION/)
|
|
116
|
+
|
|
117
|
+
console.log("action dispatch runtime tests passed")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { createRunAdmissionRuntime } from "../src/source/runtime/run-admission.js"
|
|
4
|
+
|
|
5
|
+
assert.throws(() => createRunAdmissionRuntime({}), /untilReached/)
|
|
6
|
+
assert.throws(() => createRunAdmissionRuntime({ untilReached: async () => false }), /scheduleDueWork/)
|
|
7
|
+
|
|
8
|
+
let clock = 1_000_000
|
|
9
|
+
const writes = []
|
|
10
|
+
const logs = []
|
|
11
|
+
const shellCalls = []
|
|
12
|
+
const notifications = []
|
|
13
|
+
const toasts = []
|
|
14
|
+
const schedules = []
|
|
15
|
+
const stopFiles = new Set()
|
|
16
|
+
const untilJobs = new Set()
|
|
17
|
+
const normalizePath = (value) => String(value).replace(/\\/g, "/")
|
|
18
|
+
|
|
19
|
+
const runtime = createRunAdmissionRuntime({
|
|
20
|
+
untilReached: async (_directory, job) => untilJobs.has(job.id),
|
|
21
|
+
scheduleDueWork: async (...args) => { schedules.push(args) },
|
|
22
|
+
now: () => clock,
|
|
23
|
+
pathExists: async (target) => stopFiles.has(normalizePath(target)),
|
|
24
|
+
writeState: async (...args) => { writes.push(args) },
|
|
25
|
+
appendLoopLog: async (...args) => { logs.push(args) },
|
|
26
|
+
runShellCommand: async (command) => {
|
|
27
|
+
shellCalls.push(command)
|
|
28
|
+
if (command.includes("fail")) return { code: 7, stdout: "bad", stderr: "preflight error" }
|
|
29
|
+
return { code: 0, stdout: "ok", stderr: "" }
|
|
30
|
+
},
|
|
31
|
+
notifyJob: async (...args) => { notifications.push(args) },
|
|
32
|
+
toast: async (...args) => { toasts.push(args) },
|
|
33
|
+
dangerousShell: (command) => command.includes("danger"),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const dueState = {
|
|
37
|
+
jobs: [
|
|
38
|
+
{ id: "disabled", enabled: false, intervalMs: 0 },
|
|
39
|
+
{ id: "paused", enabled: true, paused: true, intervalMs: 0 },
|
|
40
|
+
{ id: "maxed", enabled: true, maxRuns: 1, runCount: 1, intervalMs: 0 },
|
|
41
|
+
{ id: "goal-done", kind: "goal", goalStatus: "completed", enabled: true, intervalMs: 0 },
|
|
42
|
+
{ id: "watch-no", enabled: true, watchPaths: ["x"], watchTriggered: false, intervalMs: 0 },
|
|
43
|
+
{ id: "watch-yes", enabled: true, watchPaths: ["x"], watchTriggered: true, intervalMs: 99_999 },
|
|
44
|
+
{ id: "interval", enabled: true, intervalMs: 10_000, lastRunAt: clock - 20_000 },
|
|
45
|
+
{ id: "run-now", enabled: true, intervalMs: 999_999, lastRunAt: clock, runNowRequestedAt: clock },
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
assert.deepEqual(runtime.dueJobs(dueState).map((job) => job.id), ["run-now", "watch-yes", "interval"])
|
|
49
|
+
assert.deepEqual(runtime.dueJobs(dueState, true).map((job) => job.id), ["run-now", "watch-no", "watch-yes", "interval"])
|
|
50
|
+
|
|
51
|
+
const expired = {
|
|
52
|
+
id: "expired",
|
|
53
|
+
name: "expired-job",
|
|
54
|
+
enabled: true,
|
|
55
|
+
createdAt: new Date(clock - 20_000).toISOString(),
|
|
56
|
+
maxRuntimeMs: 10_000,
|
|
57
|
+
}
|
|
58
|
+
const expiredState = { jobs: [expired, { id: "keep", enabled: true }] }
|
|
59
|
+
const expiredResult = await runtime.admitJob("/repo", {}, "session-expired", expiredState, expired)
|
|
60
|
+
assert.deepEqual(expiredResult, { admitted: false, reason: "max_runtime_reached" })
|
|
61
|
+
assert.deepEqual(expiredState.jobs.map((job) => job.id), ["keep"])
|
|
62
|
+
assert.equal(notifications.at(-1)[2], "max_runtime_reached")
|
|
63
|
+
assert.equal(logs.at(-1)[1], "max-runtime")
|
|
64
|
+
assert.match(toasts.at(-1)[1], /--max-runtime/)
|
|
65
|
+
assert.equal(schedules.at(-1)[2], "session-expired")
|
|
66
|
+
|
|
67
|
+
stopFiles.add(normalizePath(path.resolve("/repo", "STOP")))
|
|
68
|
+
const stopped = { id: "stopped", enabled: true, stopFile: "STOP" }
|
|
69
|
+
const stoppedState = { jobs: [stopped] }
|
|
70
|
+
const stoppedResult = await runtime.admitJob("/repo", {}, "session-stop", stoppedState, stopped)
|
|
71
|
+
assert.deepEqual(stoppedResult, { admitted: false, reason: "stop_file" })
|
|
72
|
+
assert.equal(stoppedState.jobs.length, 0)
|
|
73
|
+
assert.equal(notifications.at(-1)[2], "stop_file")
|
|
74
|
+
assert.match(toasts.at(-1)[1], /--stop-file/)
|
|
75
|
+
|
|
76
|
+
untilJobs.add("until-job")
|
|
77
|
+
const untilJob = { id: "until-job", enabled: true, until: "DONE" }
|
|
78
|
+
const untilState = { jobs: [untilJob] }
|
|
79
|
+
const untilResult = await runtime.admitJob("/repo", {}, "session-until", untilState, untilJob)
|
|
80
|
+
assert.deepEqual(untilResult, { admitted: false, reason: "until_reached" })
|
|
81
|
+
assert.equal(untilState.jobs.length, 0)
|
|
82
|
+
assert.equal(notifications.at(-1)[2], "until_reached")
|
|
83
|
+
assert.match(toasts.at(-1)[1], /--until/)
|
|
84
|
+
|
|
85
|
+
const blocked = {
|
|
86
|
+
id: "blocked-preflight",
|
|
87
|
+
enabled: true,
|
|
88
|
+
safe: true,
|
|
89
|
+
preflightCommand: "danger --all",
|
|
90
|
+
runNowRequestedAt: clock,
|
|
91
|
+
}
|
|
92
|
+
const blockedState = { jobs: [blocked] }
|
|
93
|
+
const shellCountBeforeBlock = shellCalls.length
|
|
94
|
+
const blockedResult = await runtime.admitJob("/repo", {}, "session-blocked", blockedState, blocked)
|
|
95
|
+
assert.deepEqual(blockedResult, { admitted: false, reason: "preflight_blocked" })
|
|
96
|
+
assert.equal(shellCalls.length, shellCountBeforeBlock)
|
|
97
|
+
assert.equal(blocked.paused, true)
|
|
98
|
+
assert.equal(blocked.runNowRequestedAt, undefined)
|
|
99
|
+
assert.equal(notifications.at(-1)[2], "preflight_blocked")
|
|
100
|
+
assert.equal(writes.at(-1)[2], blockedState)
|
|
101
|
+
|
|
102
|
+
const failed = {
|
|
103
|
+
id: "failed-preflight",
|
|
104
|
+
enabled: true,
|
|
105
|
+
preflightCommand: "preflight-fail",
|
|
106
|
+
runNowRequestedAt: clock,
|
|
107
|
+
failureCount: 2,
|
|
108
|
+
}
|
|
109
|
+
const failedState = { jobs: [failed] }
|
|
110
|
+
const failedResult = await runtime.admitJob("/repo", {}, "session-failed", failedState, failed)
|
|
111
|
+
assert.deepEqual(failedResult, { admitted: false, reason: "preflight_failed" })
|
|
112
|
+
assert.equal(failed.paused, true)
|
|
113
|
+
assert.equal(failed.failureCount, 3)
|
|
114
|
+
assert.equal(failed.runNowRequestedAt, undefined)
|
|
115
|
+
assert.match(failed.lastPreflightFailure, /exit=7/)
|
|
116
|
+
assert.match(failed.lastPreflightFailure, /preflight error/)
|
|
117
|
+
assert.equal(logs.findLast((entry) => entry[1] === "preflight")?.[2].code, 7)
|
|
118
|
+
assert.equal(notifications.at(-1)[2], "preflight_failed")
|
|
119
|
+
|
|
120
|
+
const admitted = {
|
|
121
|
+
id: "admitted",
|
|
122
|
+
enabled: true,
|
|
123
|
+
preflightCommand: "preflight-ok",
|
|
124
|
+
runNowRequestedAt: clock,
|
|
125
|
+
}
|
|
126
|
+
const admittedState = { jobs: [admitted] }
|
|
127
|
+
const admittedResult = await runtime.admitJob("/repo", {}, "session-admitted", admittedState, admitted)
|
|
128
|
+
assert.deepEqual(admittedResult, { admitted: true, job: admitted, runNowRequested: true })
|
|
129
|
+
assert.equal(admitted.paused, undefined)
|
|
130
|
+
assert.equal(admitted.runNowRequestedAt, clock)
|
|
131
|
+
assert.equal(logs.findLast((entry) => entry[1] === "preflight")?.[2].code, 0)
|
|
132
|
+
|
|
133
|
+
console.log("run admission runtime tests passed")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createRunFinalizationRuntime } from "../src/source/runtime/run-finalization.js"
|
|
3
|
+
|
|
4
|
+
const noop = async () => {}
|
|
5
|
+
assert.throws(() => createRunFinalizationRuntime({}), /runGoalChecks/)
|
|
6
|
+
assert.throws(() => createRunFinalizationRuntime({ runGoalChecks: noop }), /applyGoalNoProgressGuard/)
|
|
7
|
+
assert.throws(() => createRunFinalizationRuntime({ runGoalChecks: noop, applyGoalNoProgressGuard: noop }), /createCheckpoint/)
|
|
8
|
+
assert.throws(() => createRunFinalizationRuntime({ runGoalChecks: noop, applyGoalNoProgressGuard: noop, createCheckpoint: noop }), /scheduleDueWork/)
|
|
9
|
+
|
|
10
|
+
let clock = 1000
|
|
11
|
+
const shellCalls = []
|
|
12
|
+
const writes = []
|
|
13
|
+
const logs = []
|
|
14
|
+
const notifications = []
|
|
15
|
+
const toasts = []
|
|
16
|
+
const checkpoints = []
|
|
17
|
+
const schedules = []
|
|
18
|
+
const goalChecks = []
|
|
19
|
+
const progressGuards = []
|
|
20
|
+
const reports = []
|
|
21
|
+
|
|
22
|
+
const runtime = createRunFinalizationRuntime({
|
|
23
|
+
runGoalChecks: async (_directory, sessionID, job) => {
|
|
24
|
+
goalChecks.push([sessionID, job.id])
|
|
25
|
+
return { ...job, checksRan: true }
|
|
26
|
+
},
|
|
27
|
+
applyGoalNoProgressGuard: async (_directory, _client, sessionID, job, previousJob) => {
|
|
28
|
+
progressGuards.push([sessionID, job.id, previousJob?.id])
|
|
29
|
+
return { ...job, guardRan: true }
|
|
30
|
+
},
|
|
31
|
+
createCheckpoint: async (...args) => { checkpoints.push(args) },
|
|
32
|
+
scheduleDueWork: async (...args) => { schedules.push(args) },
|
|
33
|
+
now: () => ++clock,
|
|
34
|
+
writeState: async (...args) => { writes.push(args) },
|
|
35
|
+
appendLoopLog: async (...args) => { logs.push(args) },
|
|
36
|
+
runShellCommand: async (command) => {
|
|
37
|
+
shellCalls.push(command)
|
|
38
|
+
if (command.includes("verify-fail")) return { code: 1, stdout: "bad", stderr: "verify error" }
|
|
39
|
+
if (command.includes("post-fail")) return { code: 2, stdout: "post bad", stderr: "post error" }
|
|
40
|
+
return { code: 0, stdout: "ok", stderr: "" }
|
|
41
|
+
},
|
|
42
|
+
notifyJob: async (...args) => { notifications.push(args) },
|
|
43
|
+
toast: async (...args) => { toasts.push(args) },
|
|
44
|
+
writeGoalReport: async (...args) => { reports.push(args) },
|
|
45
|
+
dangerousShell: (command) => command.includes("danger"),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const passing = { id: "passing", enabled: true, verifyCommand: "verify-ok", failureCount: 2 }
|
|
49
|
+
const passingState = { jobs: [passing] }
|
|
50
|
+
const passingResult = await runtime.finalizeJob("/repo", {}, "session-pass", passingState, passing, { id: "before-pass" })
|
|
51
|
+
assert.equal(passingResult.lastVerifyCode, 0)
|
|
52
|
+
assert.equal(passingResult.failureCount, 0)
|
|
53
|
+
assert.equal(passingResult.lastVerifyFailure, "")
|
|
54
|
+
assert.equal(writes.at(-1)[2].jobs[0].id, "passing")
|
|
55
|
+
assert.equal(checkpoints.at(-1)[2].id, "passing")
|
|
56
|
+
assert.equal(schedules.at(-1)[2], "session-pass")
|
|
57
|
+
assert.match(toasts.at(-1)[1], /Loop verify passed/)
|
|
58
|
+
|
|
59
|
+
const failing = {
|
|
60
|
+
id: "failing",
|
|
61
|
+
enabled: true,
|
|
62
|
+
verifyCommand: "verify-fail",
|
|
63
|
+
pauseOnVerifyFail: true,
|
|
64
|
+
failureCount: 0,
|
|
65
|
+
}
|
|
66
|
+
const failingState = { jobs: [failing] }
|
|
67
|
+
await runtime.finalizeJob("/repo", {}, "session-fail", failingState, failing, { id: "before-fail" })
|
|
68
|
+
assert.equal(failing.lastVerifyCode, 1)
|
|
69
|
+
assert.equal(failing.failureCount, 1)
|
|
70
|
+
assert.equal(failing.paused, true)
|
|
71
|
+
assert.match(failing.lastVerifyFailure, /verify error/)
|
|
72
|
+
assert.equal(notifications.at(-1)[2], "verify_failed")
|
|
73
|
+
assert.equal(logs.at(-1)[1], "verify")
|
|
74
|
+
|
|
75
|
+
const blockedPostrun = {
|
|
76
|
+
id: "blocked-postrun",
|
|
77
|
+
enabled: true,
|
|
78
|
+
postrunCommand: "danger --all",
|
|
79
|
+
safe: true,
|
|
80
|
+
}
|
|
81
|
+
const blockedState = { jobs: [blockedPostrun] }
|
|
82
|
+
const shellCountBeforeBlocked = shellCalls.length
|
|
83
|
+
await runtime.finalizeJob("/repo", {}, "session-blocked", blockedState, blockedPostrun, { id: "before-blocked" })
|
|
84
|
+
assert.equal(shellCalls.length, shellCountBeforeBlocked)
|
|
85
|
+
assert.equal(logs.findLast((entry) => entry[1] === "postrun-blocked")?.[2].command, "danger --all")
|
|
86
|
+
|
|
87
|
+
const failedPostrun = {
|
|
88
|
+
id: "failed-postrun",
|
|
89
|
+
enabled: true,
|
|
90
|
+
postrunCommand: "post-fail",
|
|
91
|
+
maxFailures: 1,
|
|
92
|
+
failureCount: 0,
|
|
93
|
+
}
|
|
94
|
+
const failedPostrunState = { jobs: [failedPostrun] }
|
|
95
|
+
await runtime.finalizeJob("/repo", {}, "session-post-fail", failedPostrunState, failedPostrun, { id: "before-post-fail" })
|
|
96
|
+
assert.equal(failedPostrun.lastPostrunCode, 2)
|
|
97
|
+
assert.equal(failedPostrun.failureCount, 1)
|
|
98
|
+
assert.equal(failedPostrun.paused, true)
|
|
99
|
+
assert.equal(notifications.at(-1)[2], "postrun_failed")
|
|
100
|
+
|
|
101
|
+
const goal = {
|
|
102
|
+
id: "goal-job",
|
|
103
|
+
kind: "goal",
|
|
104
|
+
enabled: false,
|
|
105
|
+
paused: true,
|
|
106
|
+
goalStatus: "completed",
|
|
107
|
+
}
|
|
108
|
+
const otherDisabled = { id: "other-disabled", enabled: false }
|
|
109
|
+
const goalState = { jobs: [goal, otherDisabled] }
|
|
110
|
+
const goalResult = await runtime.finalizeJob("/repo", {}, "session-goal", goalState, goal, { id: "before-goal" })
|
|
111
|
+
assert.equal(goalResult.checksRan, true)
|
|
112
|
+
assert.equal(goalResult.guardRan, true)
|
|
113
|
+
assert.deepEqual(goalChecks.at(-1), ["session-goal", "goal-job"])
|
|
114
|
+
assert.deepEqual(progressGuards.at(-1), ["session-goal", "goal-job", "before-goal"])
|
|
115
|
+
assert.equal(goalState.jobs.length, 1)
|
|
116
|
+
assert.equal(goalState.jobs[0].id, "goal-job")
|
|
117
|
+
assert.equal(reports.at(-1)[2].id, "goal-job")
|
|
118
|
+
|
|
119
|
+
console.log("run finalization runtime tests passed")
|