@bybrawe/opencode-loop 0.5.32 → 0.5.34
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 +17 -0
- package/README.md +197 -283
- package/commands/loop.md +1 -1
- package/package.json +3 -3
- package/scripts/action-dispatch-test.mjs +117 -0
- package/scripts/comprehensive-test.mjs +1 -1
- package/scripts/host-loop-canary.mjs +8 -3
- package/scripts/loop-command-handlers-test.mjs +33 -4
- package/scripts/loop-registration-test.mjs +77 -3
- package/scripts/run-admission-test.mjs +133 -0
- package/scripts/run-finalization-test.mjs +119 -0
- package/scripts/schedule-policy-test.mjs +114 -0
- package/scripts/schedule-syntax-test.mjs +75 -0
- package/scripts/session-status-idle-recovery-test.mjs +44 -0
- package/src/index.js +916 -473
- package/src/source/core/continuation.js +20 -0
- package/src/source/core/jobs.js +3 -0
- package/src/source/core/schedule-syntax.js +93 -0
- package/src/source/opencode/loop-commands.js +39 -30
- package/src/source/opencode/loop-registration.js +40 -3
- package/src/source/runtime/action-dispatch.js +108 -0
- package/src/source/runtime/companion-goal.js +37 -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/loop-diagnostics.js +45 -0
- package/src/source/runtime/run-admission.js +121 -0
- package/src/source/runtime/run-finalization.js +100 -0
- package/src/source/runtime/schedule-policy.js +80 -0
- package/src/source/runtime/scheduler-diagnostics.js +30 -0
- package/src/source/runtime/scheduler.js +10 -23
- package/src/source/runtime/session-status.js +28 -4
package/commands/loop.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.34",
|
|
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/continuation.js && node --check src/source/core/schedule-syntax.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/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.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/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-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/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-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")
|
|
@@ -243,7 +243,7 @@ async function testParserAndPresets() {
|
|
|
243
243
|
assert.match(preset.action, /Test command hint: npm run ci/)
|
|
244
244
|
|
|
245
245
|
const toastCount = h.records.toasts.length
|
|
246
|
-
await h.command("loop", "
|
|
246
|
+
await h.command("loop", "every nope continue")
|
|
247
247
|
await h.command("loop", "5m")
|
|
248
248
|
assert.equal(h.records.toasts.length, toastCount + 2)
|
|
249
249
|
assert.ok(h.records.toasts.slice(-2).every((item) => item.variant === "warning"))
|
|
@@ -10,7 +10,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"
|
|
|
10
10
|
|
|
11
11
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
12
|
const isWindows = process.platform === "win32"
|
|
13
|
-
const LOOP_OBJECTIVE = "
|
|
13
|
+
const LOOP_OBJECTIVE = "devam et"
|
|
14
14
|
const RUN_NOW_NATURAL_OBJECTIVE = "real host loop-now natural canary"
|
|
15
15
|
const RUN_NOW_TARGET_OBJECTIVE = "real host loop-now target canary"
|
|
16
16
|
|
|
@@ -332,7 +332,9 @@ async function main() {
|
|
|
332
332
|
signal: AbortSignal.timeout(timeoutMs),
|
|
333
333
|
})
|
|
334
334
|
|
|
335
|
-
|
|
335
|
+
// No duration token on purpose: this proves `/loop devam et` is the real idle-loop
|
|
336
|
+
// shorthand on a live OpenCode host, not only a parser-unit-test behavior.
|
|
337
|
+
const command = sendCommand("loop", `--max-runs 3 ${LOOP_OBJECTIVE}`).catch((error) => {
|
|
336
338
|
commandError = error
|
|
337
339
|
return null
|
|
338
340
|
})
|
|
@@ -356,7 +358,10 @@ async function main() {
|
|
|
356
358
|
try { persisted = JSON.parse(await readFile(stateFile, "utf8")) } catch {}
|
|
357
359
|
if (persisted?.jobs?.length) {
|
|
358
360
|
const loop = persisted.jobs.find((item) => item.name === "default" || item.action === LOOP_OBJECTIVE)
|
|
359
|
-
if (loop)
|
|
361
|
+
if (loop) {
|
|
362
|
+
assert.equal(loop.scheduleMode, "idle", `durationless continuation must persist as idle mode: ${JSON.stringify(loop)}`)
|
|
363
|
+
assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(loop)}`)
|
|
364
|
+
}
|
|
360
365
|
}
|
|
361
366
|
|
|
362
367
|
await sendCommand("loop", `10m --no-now --name natural --multi --max-runs 1 ${RUN_NOW_NATURAL_OBJECTIVE}`)
|
|
@@ -53,6 +53,8 @@ function harness(initialStates = {}, overrides = {}) {
|
|
|
53
53
|
appendLoopLog: async (...args) => { logs.push(args) },
|
|
54
54
|
readFile: overrides.readFile || (async () => { throw new Error("missing") }),
|
|
55
55
|
writeFile: async (...args) => { fileWrites.push(args) },
|
|
56
|
+
listPersistedLoopSessions: overrides.listPersistedLoopSessions || (async () => []),
|
|
57
|
+
findDedicatedGoalForSession: overrides.findDedicatedGoalForSession || (async () => undefined),
|
|
56
58
|
runtimeVersion: "v-test",
|
|
57
59
|
runtimePlatform: "test-platform",
|
|
58
60
|
})
|
|
@@ -148,7 +150,9 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD
|
|
|
148
150
|
const text = h.messages[0][2]
|
|
149
151
|
assert.match(text, /OpenCode loop status:/)
|
|
150
152
|
assert.match(text, /1\. dev \(dev\): dev: 10s \[prompt\] -> Work on dev/)
|
|
151
|
-
assert.match(text, /
|
|
153
|
+
assert.match(text, /schedule=every 10s, starts on next idle/)
|
|
154
|
+
assert.match(text, /state=due in 5s/)
|
|
155
|
+
assert.match(text, /runs=3 \| failures=1/)
|
|
152
156
|
assert.match(text, /active,safe,ask-never,no-overlap/)
|
|
153
157
|
assert.match(text, /goal:blocked,paused,checkpoint-only,git-checkpoint/)
|
|
154
158
|
}
|
|
@@ -166,7 +170,19 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD
|
|
|
166
170
|
},
|
|
167
171
|
}, { now: () => 10_000 })
|
|
168
172
|
await h.handlers.statusLoop("/work", {}, sessionID)
|
|
169
|
-
assert.match(h.messages[0][2], /due in 5s/, "--no-now status must count the first interval from createdAt")
|
|
173
|
+
assert.match(h.messages[0][2], /state=due in 5s/, "--no-now status must count the first interval from createdAt")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
const sessionID = "status-idle"
|
|
178
|
+
const h = harness({
|
|
179
|
+
[sessionID]: {
|
|
180
|
+
jobs: [loopJob("continue", { intervalMs: 0, lastRunAt: 0, scheduleMode: "idle", action: "continue" })],
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
await h.handlers.statusLoop("/work", {}, sessionID)
|
|
184
|
+
assert.match(h.messages[0][2], /schedule=every idle/)
|
|
185
|
+
assert.match(h.messages[0][2], /state=waiting for idle/)
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
{
|
|
@@ -199,6 +215,9 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD
|
|
|
199
215
|
await h.handlers.helpLoop({}, "help")
|
|
200
216
|
const text = h.messages[0][2]
|
|
201
217
|
assert.match(text, /OpenCode Loop help:/)
|
|
218
|
+
assert.match(text, /\/loop continue the project/)
|
|
219
|
+
assert.match(text, /\/loop every 5m continue the project/)
|
|
220
|
+
assert.match(text, /\/loop after 5m continue the project/)
|
|
202
221
|
assert.match(text, /\/loop-goal finish the feature/)
|
|
203
222
|
assert.match(text, /\/loop-doctor \| \/loop-init \| \/loop-export/)
|
|
204
223
|
}
|
|
@@ -231,13 +250,23 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD
|
|
|
231
250
|
|
|
232
251
|
{
|
|
233
252
|
const sessionID = "doctor"
|
|
234
|
-
const h = harness({ [sessionID]: { jobs: [loopJob("a"), loopJob("b")] } }
|
|
253
|
+
const h = harness({ [sessionID]: { jobs: [loopJob("a"), loopJob("b")] } }, {
|
|
254
|
+
listPersistedLoopSessions: async () => [
|
|
255
|
+
{ sessionID, current: true, jobs: 2, enabled: 2, neverRan: 0 },
|
|
256
|
+
{ sessionID: "other-session", current: false, jobs: 1, enabled: 1, neverRan: 1 },
|
|
257
|
+
],
|
|
258
|
+
findDedicatedGoalForSession: async () => ({ id: "goal-abcdef123456", status: "active" }),
|
|
259
|
+
})
|
|
235
260
|
await h.handlers.doctorLoop("/repo", {}, sessionID)
|
|
236
261
|
const text = h.messages[0][2]
|
|
237
262
|
assert.match(text, /OpenCode Loop doctor:/)
|
|
238
263
|
assert.match(text, /- plugin: opencode-loop/)
|
|
239
264
|
assert.match(text, /- project directory: \/repo/)
|
|
240
|
-
assert.match(text, /-
|
|
265
|
+
assert.match(text, /- current session: doctor/)
|
|
266
|
+
assert.match(text, /- current-session jobs: 2/)
|
|
267
|
+
assert.match(text, /- dedicated \/goal: active/)
|
|
268
|
+
assert.match(text, /- other persisted sessions with enabled jobs: 1/)
|
|
269
|
+
assert.match(text, /other session other-session: jobs=1, enabled=1, never-ran=1/)
|
|
241
270
|
assert.match(text, /- node: v-test/)
|
|
242
271
|
assert.match(text, /- platform: test-platform/)
|
|
243
272
|
assert.match(text, /experimental goal smoke test/)
|
|
@@ -11,6 +11,7 @@ function harness(initialStates = {}, overrides = {}) {
|
|
|
11
11
|
const messages = []
|
|
12
12
|
const logs = []
|
|
13
13
|
const contexts = new Map(Object.entries(overrides.contexts || {}))
|
|
14
|
+
const dedicatedGoals = new Map(Object.entries(overrides.dedicatedGoals || {}))
|
|
14
15
|
|
|
15
16
|
const { addLoop } = createLoopRegistration({
|
|
16
17
|
snapshotPaths: async (directory, files) => {
|
|
@@ -30,6 +31,7 @@ function harness(initialStates = {}, overrides = {}) {
|
|
|
30
31
|
appendLoopLog: async (...args) => { logs.push(args) },
|
|
31
32
|
normalizedModelRef: (value) => value ? `normalized:${value}` : undefined,
|
|
32
33
|
getSessionExecutionContext: (sessionID) => contexts.get(sessionID),
|
|
34
|
+
findDedicatedGoalForSession: async (_directory, sessionID) => dedicatedGoals.get(sessionID),
|
|
33
35
|
defaultActiveGuardMs: overrides.defaultActiveGuardMs ?? 45_000,
|
|
34
36
|
})
|
|
35
37
|
|
|
@@ -50,7 +52,7 @@ assert.equal(sameLoopDefinition(null, {}), false)
|
|
|
50
52
|
const sessionID = "invalid"
|
|
51
53
|
const h = harness()
|
|
52
54
|
const client = {}
|
|
53
|
-
await h.addLoop("/work", client, sessionID, "
|
|
55
|
+
await h.addLoop("/work", client, sessionID, "every nope continue")
|
|
54
56
|
assert.equal(h.writes.length, 0)
|
|
55
57
|
assert.equal(h.due.length, 0)
|
|
56
58
|
assert.equal(h.idle.length, 0)
|
|
@@ -58,7 +60,59 @@ assert.equal(sameLoopDefinition(null, {}), false)
|
|
|
58
60
|
assert.equal(h.toasts.length, 1)
|
|
59
61
|
assert.equal(h.toasts[0][0], client)
|
|
60
62
|
assert.equal(h.toasts[0][2], "warning")
|
|
61
|
-
assert.match(h.toasts[0][1], /
|
|
63
|
+
assert.match(h.toasts[0][1], /Invalid every schedule/)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
const sessionID = "shorthand-idle"
|
|
68
|
+
const h = harness()
|
|
69
|
+
await h.addLoop("/work", {}, sessionID, "continue the project")
|
|
70
|
+
const job = h.states.get(sessionID).jobs[0]
|
|
71
|
+
assert.equal(job.action, "continue the project")
|
|
72
|
+
assert.equal(job.intervalMs, 0)
|
|
73
|
+
assert.equal(job.scheduleMode, "idle")
|
|
74
|
+
assert.equal(job.scheduleSyntax, "idle-shorthand")
|
|
75
|
+
assert.equal(job.immediate, true)
|
|
76
|
+
assert.equal(job.maxRuns, 0)
|
|
77
|
+
assert.equal(h.idle.length, 1)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
const sessionID = "explicit-idle"
|
|
82
|
+
const h = harness()
|
|
83
|
+
await h.addLoop("/work", {}, sessionID, "idle continue forever")
|
|
84
|
+
const job = h.states.get(sessionID).jobs[0]
|
|
85
|
+
assert.equal(job.action, "continue forever")
|
|
86
|
+
assert.equal(job.intervalMs, 0)
|
|
87
|
+
assert.equal(job.scheduleMode, "idle")
|
|
88
|
+
assert.equal(job.scheduleSyntax, "idle")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
const sessionID = "every"
|
|
93
|
+
const h = harness()
|
|
94
|
+
await h.addLoop("/work", {}, sessionID, "every 5m continue the project")
|
|
95
|
+
const job = h.states.get(sessionID).jobs[0]
|
|
96
|
+
assert.equal(job.intervalMs, 300_000)
|
|
97
|
+
assert.equal(job.scheduleMode, "interval")
|
|
98
|
+
assert.equal(job.scheduleSyntax, "every")
|
|
99
|
+
assert.equal(job.immediate, false)
|
|
100
|
+
assert.equal(job.maxRuns, 0)
|
|
101
|
+
assert.equal(h.idle.length, 0)
|
|
102
|
+
assert.equal(h.due.length, 1)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
const sessionID = "after"
|
|
107
|
+
const h = harness()
|
|
108
|
+
await h.addLoop("/work", {}, sessionID, "after 5m continue once")
|
|
109
|
+
const job = h.states.get(sessionID).jobs[0]
|
|
110
|
+
assert.equal(job.intervalMs, 300_000)
|
|
111
|
+
assert.equal(job.scheduleMode, "once")
|
|
112
|
+
assert.equal(job.scheduleSyntax, "after")
|
|
113
|
+
assert.equal(job.immediate, false)
|
|
114
|
+
assert.equal(job.maxRuns, 1)
|
|
115
|
+
assert.equal(h.idle.length, 0)
|
|
62
116
|
}
|
|
63
117
|
|
|
64
118
|
{
|
|
@@ -73,6 +127,7 @@ assert.equal(sameLoopDefinition(null, {}), false)
|
|
|
73
127
|
assert.equal(job.model, "ctx-model")
|
|
74
128
|
assert.equal(job.activeRecoveryMs, 45_000)
|
|
75
129
|
assert.equal(job.immediate, true)
|
|
130
|
+
assert.equal(job.scheduleMode, "idle")
|
|
76
131
|
assert.equal(h.writes.length, 1)
|
|
77
132
|
assert.deepEqual(h.due, [["/work", client, sessionID]])
|
|
78
133
|
assert.deepEqual(h.idle, [["/work", client, sessionID]])
|
|
@@ -139,6 +194,25 @@ assert.equal(sameLoopDefinition(null, {}), false)
|
|
|
139
194
|
assert.equal(job.activeRecoveryMs, 180_000)
|
|
140
195
|
}
|
|
141
196
|
|
|
197
|
+
{
|
|
198
|
+
const sessionID = "dedicated-goal-conflict"
|
|
199
|
+
const h = harness({}, { dedicatedGoals: { [sessionID]: { id: "goal-123", status: "active", sessionID } } })
|
|
200
|
+
await h.addLoop("/work", {}, sessionID, "continue the same work")
|
|
201
|
+
assert.equal(h.writes.length, 0)
|
|
202
|
+
assert.equal(h.due.length, 0)
|
|
203
|
+
assert.equal(h.idle.length, 0)
|
|
204
|
+
assert.equal(h.logs[0][1], "goal-overlap-blocked")
|
|
205
|
+
assert.match(h.toasts[0][1], /dedicated \/goal already owns continuation/)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
const sessionID = "dedicated-goal-override"
|
|
210
|
+
const h = harness({}, { dedicatedGoals: { [sessionID]: { id: "goal-123", status: "active", sessionID } } })
|
|
211
|
+
await h.addLoop("/work", {}, sessionID, "--allow-goal-overlap continue intentionally")
|
|
212
|
+
assert.equal(h.writes.length, 1)
|
|
213
|
+
assert.equal(h.states.get(sessionID).jobs[0].allowGoalOverlap, true)
|
|
214
|
+
}
|
|
215
|
+
|
|
142
216
|
{
|
|
143
217
|
const sessionID = "long-interval"
|
|
144
218
|
const h = harness()
|
|
@@ -195,7 +269,7 @@ assert.equal(sameLoopDefinition(null, {}), false)
|
|
|
195
269
|
const sessionID = "dry-run"
|
|
196
270
|
const client = {}
|
|
197
271
|
const h = harness()
|
|
198
|
-
await h.addLoop("/work", client, sessionID, "
|
|
272
|
+
await h.addLoop("/work", client, sessionID, "--dry-run inspect only")
|
|
199
273
|
assert.equal(h.writes.length, 0)
|
|
200
274
|
assert.equal(h.due.length, 0)
|
|
201
275
|
assert.equal(h.idle.length, 0)
|
|
@@ -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")
|