@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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {
|
|
3
|
+
dueJobs,
|
|
4
|
+
inferredScheduleMode,
|
|
5
|
+
jobDueAt,
|
|
6
|
+
jobIsDue,
|
|
7
|
+
nextDueDelay,
|
|
8
|
+
scheduleDescription,
|
|
9
|
+
scheduleState,
|
|
10
|
+
} from "../src/source/runtime/schedule-policy.js"
|
|
11
|
+
|
|
12
|
+
const current = 1_000_000
|
|
13
|
+
const base = {
|
|
14
|
+
id: "job",
|
|
15
|
+
enabled: true,
|
|
16
|
+
paused: false,
|
|
17
|
+
maxRuns: 0,
|
|
18
|
+
runCount: 0,
|
|
19
|
+
intervalMs: 0,
|
|
20
|
+
immediate: true,
|
|
21
|
+
lastRunAt: 0,
|
|
22
|
+
createdAt: new Date(current - 10_000).toISOString(),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
const job = { ...base, scheduleMode: "idle" }
|
|
27
|
+
assert.equal(inferredScheduleMode(job), "idle")
|
|
28
|
+
assert.equal(jobDueAt(job, current), current)
|
|
29
|
+
assert.equal(jobIsDue(job, current), true)
|
|
30
|
+
assert.equal(scheduleDescription(job), "every idle")
|
|
31
|
+
assert.equal(scheduleState(job, current), "waiting for idle")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
const job = {
|
|
36
|
+
...base,
|
|
37
|
+
scheduleMode: "interval",
|
|
38
|
+
intervalMs: 300_000,
|
|
39
|
+
immediate: false,
|
|
40
|
+
createdAt: new Date(current).toISOString(),
|
|
41
|
+
}
|
|
42
|
+
assert.equal(jobDueAt(job, current), current + 300_000)
|
|
43
|
+
assert.equal(jobIsDue(job, current), false)
|
|
44
|
+
assert.equal(scheduleDescription(job), "every 5m, first after 5m")
|
|
45
|
+
assert.equal(scheduleState(job, current), "due in 5m")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
const job = {
|
|
50
|
+
...base,
|
|
51
|
+
scheduleMode: "interval",
|
|
52
|
+
intervalMs: 300_000,
|
|
53
|
+
immediate: true,
|
|
54
|
+
}
|
|
55
|
+
assert.equal(jobDueAt(job, current), current)
|
|
56
|
+
assert.equal(scheduleDescription(job), "every 5m, starts on next idle")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
const job = {
|
|
61
|
+
...base,
|
|
62
|
+
scheduleMode: "once",
|
|
63
|
+
intervalMs: 300_000,
|
|
64
|
+
immediate: false,
|
|
65
|
+
maxRuns: 1,
|
|
66
|
+
createdAt: new Date(current).toISOString(),
|
|
67
|
+
}
|
|
68
|
+
assert.equal(inferredScheduleMode(job), "once")
|
|
69
|
+
assert.equal(scheduleDescription(job), "once after 5m")
|
|
70
|
+
assert.equal(jobIsDue(job, current + 299_999), false)
|
|
71
|
+
assert.equal(jobIsDue(job, current + 300_000), true)
|
|
72
|
+
const completed = { ...job, runCount: 1 }
|
|
73
|
+
assert.equal(jobDueAt(completed, current + 300_000), Infinity)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
const watch = { ...base, watchPaths: ["progress.md"], watchTriggered: false }
|
|
78
|
+
assert.equal(inferredScheduleMode(watch), "watch")
|
|
79
|
+
assert.equal(jobDueAt(watch, current), Infinity)
|
|
80
|
+
assert.equal(jobDueAt({ ...watch, watchTriggered: true }, current), current)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
const expired = {
|
|
85
|
+
...base,
|
|
86
|
+
intervalMs: 3_600_000,
|
|
87
|
+
immediate: false,
|
|
88
|
+
maxRuntimeMs: 5_000,
|
|
89
|
+
createdAt: new Date(current - 6_000).toISOString(),
|
|
90
|
+
}
|
|
91
|
+
assert.equal(jobDueAt(expired, current), current, "expired max-runtime must wake admission so it can remove the job")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
const dueNow = { ...base, id: "normal" }
|
|
96
|
+
const targeted = { ...base, id: "targeted", runNowRequestedAt: current }
|
|
97
|
+
const state = { jobs: [dueNow, targeted] }
|
|
98
|
+
assert.deepEqual(dueJobs(state, current).map((job) => job.id), ["targeted", "normal"])
|
|
99
|
+
assert.equal(nextDueDelay(state, current), 0)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
const delayed = {
|
|
104
|
+
...base,
|
|
105
|
+
intervalMs: 60_000,
|
|
106
|
+
immediate: false,
|
|
107
|
+
createdAt: new Date(current).toISOString(),
|
|
108
|
+
}
|
|
109
|
+
assert.equal(nextDueDelay({ jobs: [delayed] }, current), 60_000)
|
|
110
|
+
assert.equal(jobIsDue({ ...delayed, paused: true }, current + 60_000), false)
|
|
111
|
+
assert.equal(jobIsDue({ ...delayed, enabled: false }, current + 60_000), false)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log("schedule policy tests passed")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { normalizeLoopScheduleArgs } from "../src/source/core/schedule-syntax.js"
|
|
3
|
+
|
|
4
|
+
{
|
|
5
|
+
const result = normalizeLoopScheduleArgs("continue the project")
|
|
6
|
+
assert.equal(result.ok, true)
|
|
7
|
+
assert.equal(result.args, "continue the project")
|
|
8
|
+
assert.equal(result.defaults.intervalMs, 0)
|
|
9
|
+
assert.equal(result.defaults.immediate, true)
|
|
10
|
+
assert.equal(result.scheduleMode, "idle")
|
|
11
|
+
assert.equal(result.scheduleSyntax, "idle-shorthand")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
const result = normalizeLoopScheduleArgs("--safe --ask-never continue the project")
|
|
16
|
+
assert.equal(result.ok, true)
|
|
17
|
+
assert.equal(result.defaults.intervalMs, 0)
|
|
18
|
+
assert.equal(result.args, "--safe --ask-never continue the project")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
const result = normalizeLoopScheduleArgs("idle continue forever")
|
|
23
|
+
assert.equal(result.args, "continue forever")
|
|
24
|
+
assert.equal(result.defaults.intervalMs, 0)
|
|
25
|
+
assert.equal(result.defaults.immediate, true)
|
|
26
|
+
assert.equal(result.scheduleMode, "idle")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
const result = normalizeLoopScheduleArgs("every 5m continue forever")
|
|
31
|
+
assert.equal(result.args, "continue forever")
|
|
32
|
+
assert.equal(result.defaults.intervalMs, 300_000)
|
|
33
|
+
assert.equal(result.defaults.immediate, false)
|
|
34
|
+
assert.equal(result.scheduleMode, "interval")
|
|
35
|
+
assert.equal(result.scheduleSyntax, "every")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
const result = normalizeLoopScheduleArgs("after 5m continue once")
|
|
40
|
+
assert.equal(result.args, "continue once")
|
|
41
|
+
assert.equal(result.defaults.intervalMs, 300_000)
|
|
42
|
+
assert.equal(result.defaults.immediate, false)
|
|
43
|
+
assert.equal(result.defaults.maxRuns, 1)
|
|
44
|
+
assert.equal(result.scheduleMode, "once")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
const result = normalizeLoopScheduleArgs("in 30s ping once")
|
|
49
|
+
assert.equal(result.args, "ping once")
|
|
50
|
+
assert.equal(result.defaults.intervalMs, 30_000)
|
|
51
|
+
assert.equal(result.defaults.maxRuns, 1)
|
|
52
|
+
assert.equal(result.scheduleSyntax, "after")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
const result = normalizeLoopScheduleArgs("5m continue legacy")
|
|
57
|
+
assert.equal(result.args, "5m continue legacy")
|
|
58
|
+
assert.equal(result.scheduleMode, "interval")
|
|
59
|
+
assert.equal(result.scheduleSyntax, "legacy")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
const result = normalizeLoopScheduleArgs("--allow-goal-overlap continue")
|
|
64
|
+
assert.equal(result.args, "continue")
|
|
65
|
+
assert.equal(result.allowGoalOverlap, true)
|
|
66
|
+
assert.equal(result.scheduleMode, "idle")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
const result = normalizeLoopScheduleArgs("every nope continue")
|
|
71
|
+
assert.equal(result.ok, false)
|
|
72
|
+
assert.match(result.error, /Invalid every schedule/)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log("schedule syntax tests passed")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { clearSessionActivity } from "../src/source/runtime/session-activity.js"
|
|
3
|
+
import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
|
|
4
|
+
|
|
5
|
+
const activeRuns = new Map()
|
|
6
|
+
const logs = []
|
|
7
|
+
let clock = 100_000
|
|
8
|
+
let completion = "completed"
|
|
9
|
+
|
|
10
|
+
const runtime = createSessionStatusRuntime({
|
|
11
|
+
activeRuns,
|
|
12
|
+
now: () => clock,
|
|
13
|
+
sessionStatusCacheMs: 0,
|
|
14
|
+
appendLoopLog: async (...args) => logs.push(args),
|
|
15
|
+
activeRunCompletionFromMessages: async () => completion,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const busyClient = {
|
|
19
|
+
session: {
|
|
20
|
+
status: async () => ({ data: { session: { type: "busy" } } }),
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
runtime.markSessionStatus("session", "busy", clock - 2_000)
|
|
26
|
+
assert.equal(await runtime.sessionStatusType(busyClient, "session", "/repo"), "idle")
|
|
27
|
+
assert.equal(logs.at(-1)[1], "status-message-idle-recovery")
|
|
28
|
+
assert.equal(logs.at(-1)[2].staleStatus, "busy")
|
|
29
|
+
|
|
30
|
+
runtime.clearSessionStatus("session")
|
|
31
|
+
completion = "incomplete"
|
|
32
|
+
clock += 5_000
|
|
33
|
+
assert.equal(await runtime.sessionStatusType(busyClient, "session", "/repo"), "busy", "an unfinished tail must never be force-recovered")
|
|
34
|
+
|
|
35
|
+
runtime.clearSessionStatus("session")
|
|
36
|
+
completion = "unknown"
|
|
37
|
+
clock += 5_000
|
|
38
|
+
assert.equal(await runtime.sessionStatusType(busyClient, "session", "/repo"), "busy", "unknown tail completion must remain conservative")
|
|
39
|
+
|
|
40
|
+
console.log("session status idle recovery tests passed")
|
|
41
|
+
} finally {
|
|
42
|
+
activeRuns.clear()
|
|
43
|
+
clearSessionActivity("session")
|
|
44
|
+
}
|