@bybrawe/opencode-loop 0.5.33 → 0.5.35

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/commands/loop.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Start an OpenCode auto-continue loop. Usage: /loop 5m <task>
2
+ description: "Auto-continue or schedule work. /loop <task> = every idle; /loop every 5m <task> = recurring; /loop after 5m <task> = once."
3
3
  agent: opencode-loop-local
4
4
  ---
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.33",
3
+ "version": "0.5.35",
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/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",
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/network-recovery.js && node --check src/source/runtime/terminal-guard.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/network-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/network-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"
@@ -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", "nonsense")
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 = "real host loop canary"
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
- const command = sendCommand("loop", `0s --max-runs 3 ${LOOP_OBJECTIVE}`).catch((error) => {
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) assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(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, /runs=3 \| failures=1 \| due in 5s/)
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, /- active jobs: 2/)
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, "not-a-duration and no defaults")
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], /Usage: \/loop/)
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, "0s --dry-run inspect only")
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,218 @@
1
+ import assert from "node:assert/strict"
2
+ import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../src/source/core/continuation.js"
3
+ import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "../src/source/runtime/network-recovery.js"
4
+ import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
5
+ import { applyTerminalContinuationGuard } from "../src/source/runtime/terminal-guard.js"
6
+ import { createLoopExecutor } from "../src/source/runtime/executor.js"
7
+ import { clearSessionActivity, sessionStatuses, sessionStatusSeenAt } from "../src/source/runtime/session-activity.js"
8
+
9
+ function clone(value) { return JSON.parse(JSON.stringify(value)) }
10
+
11
+ for (const value of [
12
+ "fetch failed",
13
+ "network connection lost",
14
+ "ECONNRESET",
15
+ "EAI_AGAIN",
16
+ "ETIMEDOUT",
17
+ "socket hang up",
18
+ "HTTP 503 service unavailable",
19
+ ]) assert.equal(isTransientNetworkError(value), true, value)
20
+ assert.equal(isTransientNetworkError("HTTP 401 invalid API key"), false)
21
+ assert.equal(networkRetryDelayMs(1, 5_000, 60_000), 5_000)
22
+ assert.equal(networkRetryDelayMs(2, 5_000, 60_000), 10_000)
23
+ assert.equal(networkRetryDelayMs(9, 5_000, 60_000), 60_000)
24
+
25
+ const refunded = {
26
+ id: "limited",
27
+ runCount: 3,
28
+ maxRuns: 3,
29
+ enabled: false,
30
+ lastRunAt: 500,
31
+ failureCount: 2,
32
+ }
33
+ refundInfrastructureRun(refunded, {
34
+ runCount: 3,
35
+ previousLastRunAt: 250,
36
+ disabledByMaxRuns: true,
37
+ }, {
38
+ reason: "dispatch_failed_transient",
39
+ error: new Error("fetch failed: ECONNRESET"),
40
+ now: 1_000,
41
+ })
42
+ assert.equal(refunded.runCount, 2, "transient infrastructure attempt must not consume max-runs")
43
+ assert.equal(refunded.enabled, true, "max-runs disable caused by a transient attempt must be rolled back")
44
+ assert.equal(refunded.lastRunAt, 250)
45
+ assert.equal(refunded.failureCount, 2, "transient outage must not consume ordinary failure budget")
46
+ assert.equal(refunded.infrastructureFailureCount, 1)
47
+
48
+ {
49
+ let clock = 100_000
50
+ const activeRuns = new Map([["retry-session", { startedAt: 1, jobId: "j", job: { id: "j" } }]])
51
+ const logs = []
52
+ const runtime = createSessionStatusRuntime({
53
+ activeRuns,
54
+ now: () => clock,
55
+ staleActiveRecoveryMs: 1_000,
56
+ sessionStatusCacheMs: 0,
57
+ activeRunCompletionFromMessages: async () => "unknown",
58
+ appendLoopLog: async (...args) => logs.push(args),
59
+ })
60
+ const retryClient = { session: { status: async () => ({ data: { "retry-session": { type: "retry" } } }) } }
61
+ assert.equal(await runtime.sessionStatusType(retryClient, "retry-session", "/repo"), "retry")
62
+ clock += 120_000
63
+ assert.equal(await runtime.sessionStatusType(retryClient, "retry-session", "/repo"), "retry", "stale retry must never be converted to idle by age alone")
64
+ assert.equal(logs.some((entry) => entry[1] === "status-stale-recovery"), false)
65
+ clearSessionActivity("retry-session")
66
+ sessionStatuses.delete("retry-session")
67
+ sessionStatusSeenAt.delete("retry-session")
68
+ }
69
+
70
+ {
71
+ let clock = 100_000
72
+ const activeRuns = new Map([["offline-session", { startedAt: 1, jobId: "j", job: { id: "j" } }]])
73
+ const runtime = createSessionStatusRuntime({
74
+ activeRuns,
75
+ now: () => clock,
76
+ staleActiveRecoveryMs: 1_000,
77
+ sessionStatusCacheMs: 0,
78
+ activeRunCompletionFromMessages: async () => "unknown",
79
+ appendLoopLog: async () => {},
80
+ })
81
+ const offlineClient = { session: { status: async () => { throw new Error("network connection lost") } } }
82
+ clock += 120_000
83
+ assert.equal(await runtime.sessionStatusType(offlineClient, "offline-session", "/repo"), "busy", "failed status reads must fail closed, not become idle")
84
+ clearSessionActivity("offline-session")
85
+ sessionStatuses.delete("offline-session")
86
+ sessionStatusSeenAt.delete("offline-session")
87
+ }
88
+
89
+ assert.equal(isCompletionBoundedContinuation("devam et"), false)
90
+ assert.equal(isCompletionBoundedContinuation("devam et bitene kadar devam tamamen projeyi bitir"), true)
91
+ assert.equal(isTerminalNoWorkReply("Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata."), true)
92
+ assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build doğrulaması."), false)
93
+
94
+ {
95
+ const terminalMessages = [{
96
+ info: { role: "assistant", time: { created: 2_000, completed: 2_100 } },
97
+ parts: [{ type: "text", text: "Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata." }],
98
+ }]
99
+ const client = { session: { messages: async () => ({ data: terminalMessages }) } }
100
+ const bounded = {
101
+ id: "bounded",
102
+ action: "devam et bitene kadar devam tamamen projeyi bitir",
103
+ scheduleMode: "idle",
104
+ lastRunAt: 1_500,
105
+ paused: false,
106
+ }
107
+ let result = await applyTerminalContinuationGuard("/repo", client, "s", bounded)
108
+ assert.equal(result.terminal, true)
109
+ assert.equal(result.pausedNow, false)
110
+ assert.equal(result.job.terminalNoWorkCount, 1)
111
+ result = await applyTerminalContinuationGuard("/repo", client, "s", result.job)
112
+ assert.equal(result.pausedNow, true, "two current terminal replies should stop an explicit until-done loop")
113
+ assert.equal(result.job.paused, true)
114
+
115
+ const infinite = {
116
+ id: "infinite",
117
+ action: "devam et",
118
+ scheduleMode: "idle",
119
+ lastRunAt: 1_500,
120
+ paused: false,
121
+ }
122
+ const untouched = await applyTerminalContinuationGuard("/repo", client, "s", infinite)
123
+ assert.equal(untouched.terminal, false)
124
+ assert.equal(untouched.job.paused, false, "plain /loop devam et must remain intentionally infinite")
125
+ }
126
+
127
+ {
128
+ let clock = 1_000_000
129
+ let liveStatus = "idle"
130
+ const states = new Map()
131
+ const schedules = []
132
+ const fireCalls = []
133
+ const directory = "/repo"
134
+ const sessionID = "watchdog-session"
135
+ const stateKey = `${directory}:${sessionID}`
136
+ states.set(stateKey, { jobs: [{
137
+ id: "job",
138
+ name: "job",
139
+ action: "devam et",
140
+ scheduleMode: "idle",
141
+ enabled: true,
142
+ paused: false,
143
+ intervalMs: 0,
144
+ runCount: 0,
145
+ maxRuns: 1,
146
+ maxRuntimeMs: 0,
147
+ timeoutMs: 0,
148
+ noOverlap: true,
149
+ }] })
150
+
151
+ const executor = createLoopExecutor({
152
+ workspace: {
153
+ buildPrompt: async (_directory, job) => job.action,
154
+ ensureBranch: async (_directory, job) => job,
155
+ watchChanged: async () => false,
156
+ untilReached: async () => false,
157
+ createCheckpoint: async () => {},
158
+ },
159
+ goalPolicy: {
160
+ runGoalChecks: async (_directory, _sessionID, job) => job,
161
+ applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
162
+ },
163
+ scheduler: {
164
+ rememberSession: () => {},
165
+ scheduleDueWork: async (...args) => schedules.push(args),
166
+ },
167
+ now: () => clock,
168
+ readState: async () => clone(states.get(stateKey)),
169
+ writeState: async (_directory, _sessionID, state) => states.set(stateKey, clone(state)),
170
+ appendLoopLog: async () => {},
171
+ notifyJob: async () => {},
172
+ toast: async () => {},
173
+ runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),
174
+ fireSdk: (...args) => { fireCalls.push(args); return Promise.resolve({ data: {} }) },
175
+ sdkCall: async () => ({}),
176
+ normalizedModelRef: () => undefined,
177
+ compactTuiCommandName: () => undefined,
178
+ guardLoopOwnedUserMessage: () => {},
179
+ dangerousShell: () => false,
180
+ activeRunCompletionFromMessages: async () => "unknown",
181
+ busyRetryMs: 5,
182
+ providerRetryWatchdogMs: 20,
183
+ networkRetryMaxMs: 100,
184
+ })
185
+
186
+ const client = {
187
+ session: {
188
+ status: async () => ({ data: liveStatus === "idle" ? {} : { [sessionID]: { type: liveStatus } } }),
189
+ prompt: async () => ({ data: {} }),
190
+ abort: async () => ({ data: {} }),
191
+ },
192
+ }
193
+
194
+ await executor.maybeRunDueJobs(directory, client, sessionID)
195
+ assert.ok(executor.getActiveRun(sessionID), "first logical run should be active")
196
+ assert.equal(states.get(stateKey).jobs[0].runCount, 1)
197
+ assert.equal(states.get(stateKey).jobs[0].enabled, false, "max-runs can temporarily disable while the real turn is in flight")
198
+
199
+ liveStatus = "retry"
200
+ clock += 5
201
+ await executor.maybeRunDueJobs(directory, client, sessionID)
202
+ assert.ok(executor.getActiveRun(sessionID), "first retry observation should remain host-owned")
203
+ clock += 25
204
+ await executor.maybeRunDueJobs(directory, client, sessionID)
205
+
206
+ assert.equal(executor.getActiveRun(sessionID), undefined, "stuck Loop-owned retry should be released after watchdog")
207
+ const job = states.get(stateKey).jobs[0]
208
+ assert.equal(job.runCount, 0, "network watchdog recovery must refund the logical run")
209
+ assert.equal(job.enabled, true, "refunded max-runs job should be runnable again")
210
+ assert.equal(job.infrastructureFailureCount, 1)
211
+ assert.ok(fireCalls.some((call) => String(call[1]).includes("provider retry watchdog")), "watchdog must abort only the Loop-owned stuck turn")
212
+ assert.ok(schedules.some((call) => call[3] >= 5), "recovery must reschedule with backoff")
213
+ clearSessionActivity(sessionID)
214
+ sessionStatuses.delete(sessionID)
215
+ sessionStatusSeenAt.delete(sessionID)
216
+ }
217
+
218
+ console.log("network recovery + terminal continuation guard: ok")
@@ -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")