@bybrawe/opencode-loop 0.5.28 → 0.5.29

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/scheduler-runtime-test.mjs +147 -0
  21. package/scripts/session-activity-test.mjs +95 -0
  22. package/scripts/session-status-test.mjs +220 -0
  23. package/scripts/v2-command-adapter-test.mjs +113 -0
  24. package/scripts/v2-command-runtime-test.mjs +98 -0
  25. package/scripts/v2-host-contract-test.mjs +27 -0
  26. package/scripts/v2-plugin-sentinel-test.mjs +57 -8
  27. package/scripts/v2-prompt-interval-test.mjs +171 -0
  28. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  29. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  30. package/src/index.js +2751 -1594
  31. package/src/source/legacy-v1.js +168 -1860
  32. package/src/source/opencode/command-router.js +103 -0
  33. package/src/source/opencode/commands.js +83 -0
  34. package/src/source/opencode/goal-commands.js +107 -0
  35. package/src/source/opencode/loop-commands.js +195 -0
  36. package/src/source/opencode/loop-registration.js +100 -0
  37. package/src/source/opencode/messages.js +55 -0
  38. package/src/source/opencode2/capabilities.js +3 -1
  39. package/src/source/opencode2/commands.js +60 -0
  40. package/src/source/opencode2/event-bridge.js +19 -1
  41. package/src/source/opencode2/events.js +64 -0
  42. package/src/source/opencode2/experimental.js +32 -1
  43. package/src/source/opencode2/host-contract.js +26 -0
  44. package/src/source/opencode2/prompt-runtime.js +381 -0
  45. package/src/source/opencode2/runtime-adapter.js +56 -0
  46. package/src/source/opencode2/status.js +40 -0
  47. package/src/source/runtime/compaction.js +122 -0
  48. package/src/source/runtime/executor.js +558 -0
  49. package/src/source/runtime/goal-policy.js +77 -0
  50. package/src/source/runtime/goal-runtime.js +231 -0
  51. package/src/source/runtime/goal-steering.js +198 -0
  52. package/src/source/runtime/job-workspace.js +148 -0
  53. package/src/source/runtime/scheduler.js +212 -0
  54. package/src/source/runtime/session-activity.js +112 -0
  55. package/src/source/runtime/session-status.js +197 -0
  56. package/scripts/host-bridge-parity-test.mjs +0 -22
@@ -0,0 +1,558 @@
1
+ import path from "node:path"
2
+ import { now as defaultNow, splitFirst } from "../core/args.js"
3
+ import { actionKind, isGoalJob } from "../core/jobs.js"
4
+ import { pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
5
+ import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
6
+ import { sdkErrorMessage as defaultErrorMessage, sdkCall as defaultSdkCall } from "../opencode/sdk.js"
7
+ import { normalizedModelRef as defaultNormalizedModelRef } from "../opencode/session-context.js"
8
+ import { fireSdk as defaultFireSdk, compactTuiCommandName as defaultCompactTuiCommandName, log as defaultLog, toast as defaultToast } from "../opencode/host.js"
9
+ import { guardLoopOwnedUserMessage as defaultGuardLoopOwnedUserMessage } from "../opencode/messages.js"
10
+ import { writeGoalReport as defaultWriteGoalReport } from "./goal-runtime.js"
11
+ import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
12
+ import { createSessionStatusRuntime } from "./session-status.js"
13
+ import { createCompactionRuntime } from "./compaction.js"
14
+
15
+ const DEFAULT_ACTIVE_GUARD_MS = 45_000
16
+ const DEFAULT_BUSY_RETRY_MS = 5_000
17
+
18
+ function requireFunction(value, label) {
19
+ if (typeof value !== "function") throw new TypeError(`createLoopExecutor requires ${label}`)
20
+ return value
21
+ }
22
+
23
+ export function createLoopExecutor(options = {}) {
24
+ const workspace = options.workspace || {}
25
+ const goalPolicy = options.goalPolicy || {}
26
+ const scheduler = options.scheduler || {}
27
+
28
+ const buildPrompt = requireFunction(workspace.buildPrompt, "workspace.buildPrompt")
29
+ const ensureBranch = requireFunction(workspace.ensureBranch, "workspace.ensureBranch")
30
+ const watchChanged = requireFunction(workspace.watchChanged, "workspace.watchChanged")
31
+ const untilReached = requireFunction(workspace.untilReached, "workspace.untilReached")
32
+ const createCheckpoint = requireFunction(workspace.createCheckpoint, "workspace.createCheckpoint")
33
+ const runGoalChecks = requireFunction(goalPolicy.runGoalChecks, "goalPolicy.runGoalChecks")
34
+ const applyGoalNoProgressGuard = requireFunction(goalPolicy.applyGoalNoProgressGuard, "goalPolicy.applyGoalNoProgressGuard")
35
+ const rememberSession = requireFunction(scheduler.rememberSession, "scheduler.rememberSession")
36
+ const scheduleDueWork = requireFunction(scheduler.scheduleDueWork, "scheduler.scheduleDueWork")
37
+
38
+ const now = typeof options.now === "function" ? options.now : defaultNow
39
+ const readState = typeof options.readState === "function" ? options.readState : defaultReadState
40
+ const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
41
+ const pathExists = typeof options.pathExists === "function" ? options.pathExists : defaultPathExists
42
+ const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
43
+ const runShellCommand = typeof options.runShellCommand === "function" ? options.runShellCommand : defaultRunShellCommand
44
+ const notifyJob = typeof options.notifyJob === "function" ? options.notifyJob : defaultNotifyJob
45
+ const errorMessage = typeof options.errorMessage === "function" ? options.errorMessage : defaultErrorMessage
46
+ const sdkCall = typeof options.sdkCall === "function" ? options.sdkCall : defaultSdkCall
47
+ const normalizedModelRef = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : defaultNormalizedModelRef
48
+ const fireSdk = typeof options.fireSdk === "function" ? options.fireSdk : defaultFireSdk
49
+ const compactTuiCommandName = typeof options.compactTuiCommandName === "function" ? options.compactTuiCommandName : defaultCompactTuiCommandName
50
+ const log = typeof options.log === "function" ? options.log : defaultLog
51
+ const toast = typeof options.toast === "function" ? options.toast : defaultToast
52
+ const guardLoopOwnedUserMessage = typeof options.guardLoopOwnedUserMessage === "function"
53
+ ? options.guardLoopOwnedUserMessage
54
+ : defaultGuardLoopOwnedUserMessage
55
+ const writeGoalReport = typeof options.writeGoalReport === "function" ? options.writeGoalReport : defaultWriteGoalReport
56
+ const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
57
+ const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0
58
+ ? Number(options.activeGuardMs)
59
+ : DEFAULT_ACTIVE_GUARD_MS
60
+ const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0
61
+ ? Number(options.busyRetryMs)
62
+ : DEFAULT_BUSY_RETRY_MS
63
+
64
+ const activeRuns = new Map()
65
+ const runLocks = new Map()
66
+
67
+ const statusRuntime = createSessionStatusRuntime({
68
+ activeRuns,
69
+ appendLoopLog,
70
+ now,
71
+ activeRunCompletionFromMessages: options.activeRunCompletionFromMessages,
72
+ staleActiveRecoveryMs: options.staleActiveRecoveryMs,
73
+ sessionStatusCacheMs: options.sessionStatusCacheMs,
74
+ })
75
+ const {
76
+ updateSessionStatusFromEvent,
77
+ staleActiveRun,
78
+ canFinalizeActiveRun,
79
+ sessionIsIdle,
80
+ markSessionStatus,
81
+ clearSessionStatus,
82
+ } = statusRuntime
83
+
84
+ const compactionRuntime = createCompactionRuntime({
85
+ activeRuns,
86
+ finalizeActiveRun,
87
+ appendLoopLog,
88
+ compactSession: options.compactSession,
89
+ log,
90
+ errorMessage,
91
+ now,
92
+ })
93
+
94
+ function dueJobs(state, force = false) {
95
+ const current = now()
96
+ const due = (state.jobs || []).filter((job) => {
97
+ if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
98
+ if (!job.enabled || job.paused) return false
99
+ if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
100
+ if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
101
+ if (Number(job.runNowRequestedAt || 0) > 0) return true
102
+ if (force) return true
103
+ if (job.watchPaths?.length) return job.watchTriggered === true
104
+ return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
105
+ })
106
+ return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0))
107
+ }
108
+
109
+ function clearActiveRun(sessionID) {
110
+ const active = activeRuns.get(sessionID)
111
+ if (active?.timer) clearTimeout(active.timer)
112
+ compactionRuntime.clearForActiveRun(sessionID, active)
113
+ activeRuns.delete(sessionID)
114
+ }
115
+
116
+ function disposeSession(sessionID) {
117
+ clearActiveRun(sessionID)
118
+ runLocks.delete(sessionID)
119
+ compactionRuntime.clear(sessionID)
120
+ clearSessionStatus(sessionID)
121
+ }
122
+
123
+ async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
124
+ const active = activeRuns.get(sessionID)
125
+ if (!active || active.jobId !== jobId || active.runToken !== runToken) return false
126
+
127
+ clearActiveRun(sessionID)
128
+ clearSessionStatus(sessionID)
129
+
130
+ const message = errorMessage(error)
131
+ const state = await readState(directory, sessionID)
132
+ const job = (state.jobs || []).find((candidate) => candidate.id === jobId)
133
+ if (job) {
134
+ job.failureCount = (job.failureCount || 0) + 1
135
+ job.lastFailureReason = "dispatch_failed"
136
+ job.lastDispatchFailure = message.slice(0, 4000)
137
+ job.lastDispatchFailureAt = now()
138
+ if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
139
+ job.paused = true
140
+ await notifyJob(directory, job, "dispatch_failed")
141
+ }
142
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
143
+ await writeState(directory, sessionID, state)
144
+ }
145
+
146
+ await appendLoopLog(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message })
147
+ await toast(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning")
148
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
149
+ return true
150
+ }
151
+
152
+ async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {
153
+ const active = activeRuns.get(sessionID)
154
+ if (!active) return
155
+ if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false
156
+ const recoveredStale = staleActiveRun(sessionID)
157
+ if (active.compactionOnly) {
158
+ const pending = compactionRuntime.getPending(sessionID)
159
+ clearActiveRun(sessionID)
160
+ clearSessionStatus(sessionID)
161
+ await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
162
+ sessionID,
163
+ job: active.job?.name || active.jobId,
164
+ startedAt: active.startedAt,
165
+ nativeEvent: Boolean(pending?.completedAt),
166
+ })
167
+ await scheduleDueWork(directory, client, sessionID)
168
+ return true
169
+ }
170
+
171
+ clearActiveRun(sessionID)
172
+ const state = await readState(directory, sessionID)
173
+ let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
174
+ if (!job) return
175
+ job.lastFinishedAt = now()
176
+ if (recoveredStale) {
177
+ await appendLoopLog(directory, "active-stale-recovery", {
178
+ sessionID,
179
+ job: job.name || job.id,
180
+ startedAt: active.startedAt,
181
+ })
182
+ }
183
+
184
+ if (job.verifyCommand) {
185
+ const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
186
+ job.lastVerifyAt = now()
187
+ job.lastVerifyCode = verify.code
188
+ if (verify.code === 0) {
189
+ job.failureCount = 0
190
+ job.lastVerifyFailure = ""
191
+ await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
192
+ } else {
193
+ job.failureCount = (job.failureCount || 0) + 1
194
+ job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
195
+ await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
196
+ if (job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) {
197
+ job.paused = true
198
+ await notifyJob(directory, job, "verify_failed")
199
+ }
200
+ }
201
+ await appendLoopLog(directory, "verify", {
202
+ sessionID,
203
+ job: job.name || job.id,
204
+ command: job.verifyCommand,
205
+ code: verify.code,
206
+ failures: job.failureCount || 0,
207
+ })
208
+ }
209
+
210
+ if (job.postrunCommand) {
211
+ if (job.safe && dangerousShell(job.postrunCommand)) {
212
+ await appendLoopLog(directory, "postrun-blocked", {
213
+ sessionID,
214
+ job: job.name || job.id,
215
+ command: job.postrunCommand,
216
+ })
217
+ } else {
218
+ const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300_000)
219
+ job.lastPostrunCode = postrun.code
220
+ job.lastPostrunAt = now()
221
+ if (postrun.code !== 0) {
222
+ job.failureCount = (job.failureCount || 0) + 1
223
+ job.lastPostrunFailure = (job.postrunCommand + "\nexit=" + postrun.code + "\n" + postrun.stdout + "\n" + postrun.stderr).slice(0, 4000)
224
+ if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
225
+ job.paused = true
226
+ await notifyJob(directory, job, "postrun_failed")
227
+ }
228
+ }
229
+ await appendLoopLog(directory, "postrun", {
230
+ sessionID,
231
+ job: job.name || job.id,
232
+ command: job.postrunCommand,
233
+ code: postrun.code,
234
+ })
235
+ }
236
+ }
237
+
238
+ if (isGoalJob(job)) {
239
+ job = await runGoalChecks(directory, sessionID, job, client)
240
+ job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
241
+ }
242
+ state.jobs = (state.jobs || [])
243
+ .map((candidate) => candidate.id === job.id ? job : candidate)
244
+ .filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
245
+ await writeState(directory, sessionID, state)
246
+ if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
247
+ await createCheckpoint(directory, sessionID, job, client)
248
+ await scheduleDueWork(directory, client, sessionID)
249
+ return true
250
+ }
251
+
252
+ async function fireAction(directory, client, sessionID, job) {
253
+ const action = String(job.action || "").trim()
254
+ const kind = actionKind(action, job)
255
+ const agent = job.agent || "build"
256
+ const model = normalizedModelRef(job.model)
257
+
258
+ if (kind === "compact") {
259
+ const ok = await compactionRuntime.start(directory, client, sessionID, job.id, model, false)
260
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
261
+ }
262
+
263
+ if (kind === "command") {
264
+ const normalized = action.startsWith("/") ? action.slice(1) : action
265
+ const [command, argumentsText] = splitFirst(normalized)
266
+ if (!command) {
267
+ await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
268
+ return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
269
+ }
270
+ const tuiCommand = compactTuiCommandName(command)
271
+ if (tuiCommand) {
272
+ guardLoopOwnedUserMessage(sessionID)
273
+ const ok = await compactionRuntime.start(directory, client, sessionID, job.id, model, false)
274
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
275
+ }
276
+ guardLoopOwnedUserMessage(sessionID)
277
+ const commandBody = { command, arguments: argumentsText, agent }
278
+ if (model) commandBody.model = `${model.providerID}/${model.modelID}`
279
+ await sdkCall(
280
+ client.session.command.bind(client.session),
281
+ { path: { id: sessionID }, body: commandBody },
282
+ { path: { sessionID }, body: commandBody },
283
+ { sessionID, ...commandBody },
284
+ )
285
+ return { startsAssistantTurn: true }
286
+ }
287
+
288
+ if (kind === "shell") {
289
+ const command = action.replace(/^[!$]\s*/, "").trim()
290
+ if (job.safe && dangerousShell(command)) {
291
+ await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
292
+ await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
293
+ return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
294
+ }
295
+ guardLoopOwnedUserMessage(sessionID)
296
+ const shellBody = { command, agent }
297
+ if (model) shellBody.model = model
298
+ const dispatch = fireSdk(
299
+ client,
300
+ "session.shell",
301
+ client.session.shell.bind(client.session),
302
+ { path: { id: sessionID }, body: shellBody },
303
+ { path: { sessionID }, body: shellBody },
304
+ { sessionID, ...shellBody },
305
+ )
306
+ return { startsAssistantTurn: true, dispatch }
307
+ }
308
+
309
+ const prompt = await buildPrompt(directory, job)
310
+ const prefix = kind === "goal"
311
+ ? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real."
312
+ : "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly."
313
+ const promptText = `${prefix}\n\n${prompt}`
314
+ guardLoopOwnedUserMessage(sessionID)
315
+ const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
316
+ if (model) promptBody.model = model
317
+ const dispatch = fireSdk(
318
+ client,
319
+ "session.prompt",
320
+ client.session.prompt.bind(client.session),
321
+ { path: { id: sessionID }, body: promptBody },
322
+ { path: { sessionID }, body: promptBody },
323
+ { sessionID, ...promptBody },
324
+ )
325
+ return { startsAssistantTurn: true, dispatch }
326
+ }
327
+
328
+ async function maybeRunDueJobs(directory, client, sessionID, runOptions = {}) {
329
+ rememberSession(directory, client, sessionID)
330
+ const reschedule = async (minDelayMs = 0) => {
331
+ await scheduleDueWork(directory, client, sessionID, minDelayMs)
332
+ }
333
+
334
+ if (runLocks.has(sessionID)) {
335
+ await reschedule(busyRetryMs)
336
+ return
337
+ }
338
+ runLocks.set(sessionID, now())
339
+ let job
340
+ try {
341
+ await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
342
+ if (!await sessionIsIdle(client, sessionID, directory)) {
343
+ if (runOptions.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
344
+ await reschedule(busyRetryMs)
345
+ return
346
+ }
347
+
348
+ const active = activeRuns.get(sessionID)
349
+ const activeAge = active ? now() - (active.startedAt || 0) : 0
350
+ const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || activeGuardMs
351
+ if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
352
+ await reschedule(busyRetryMs)
353
+ return
354
+ }
355
+ if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
356
+
357
+ const state = await readState(directory, sessionID)
358
+ for (const candidate of state.jobs || []) {
359
+ if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) {
360
+ candidate.watchTriggered = true
361
+ }
362
+ }
363
+ const due = dueJobs(state, Boolean(runOptions.force))
364
+ if (!due.length) {
365
+ await writeState(directory, sessionID, state)
366
+ await reschedule()
367
+ return
368
+ }
369
+ job = due[0]
370
+ const runNowRequested = Number(job.runNowRequestedAt || 0) > 0
371
+
372
+ if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
373
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
374
+ await writeState(directory, sessionID, state)
375
+ await notifyJob(directory, job, "max_runtime_reached")
376
+ await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
377
+ await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
378
+ await reschedule()
379
+ return
380
+ }
381
+ if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
382
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
383
+ await writeState(directory, sessionID, state)
384
+ await notifyJob(directory, job, "stop_file")
385
+ await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
386
+ await reschedule()
387
+ return
388
+ }
389
+ if (await untilReached(directory, job)) {
390
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
391
+ await writeState(directory, sessionID, state)
392
+ await notifyJob(directory, job, "until_reached")
393
+ await toast(client, `Loop stopped by --until: ${job.until}`, "success")
394
+ await reschedule()
395
+ return
396
+ }
397
+
398
+ if (job.preflightCommand) {
399
+ if (job.safe && dangerousShell(job.preflightCommand)) {
400
+ if (runNowRequested) delete job.runNowRequestedAt
401
+ job.paused = true
402
+ await writeState(directory, sessionID, state)
403
+ await notifyJob(directory, job, "preflight_blocked")
404
+ await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
405
+ await reschedule()
406
+ return
407
+ }
408
+ const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
409
+ await appendLoopLog(directory, "preflight", {
410
+ sessionID,
411
+ job: job.name || job.id,
412
+ command: job.preflightCommand,
413
+ code: preflight.code,
414
+ })
415
+ if (preflight.code !== 0) {
416
+ if (runNowRequested) delete job.runNowRequestedAt
417
+ job.paused = true
418
+ job.failureCount = (job.failureCount || 0) + 1
419
+ job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
420
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
421
+ await writeState(directory, sessionID, state)
422
+ await notifyJob(directory, job, "preflight_failed")
423
+ await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
424
+ await reschedule()
425
+ return
426
+ }
427
+ }
428
+
429
+ job = await ensureBranch(directory, job, client, sessionID)
430
+ const compactResult = await compactionRuntime.maybeCompact(directory, client, sessionID, job)
431
+ job = compactResult.job
432
+ if (compactResult.started) {
433
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
434
+ await writeState(directory, sessionID, state)
435
+ let timer
436
+ if (job.timeoutMs > 0) {
437
+ timer = setTimeout(() => {
438
+ fireSdk(
439
+ client,
440
+ "session.abort",
441
+ client.session.abort.bind(client.session),
442
+ { path: { id: sessionID }, body: {} },
443
+ { path: { sessionID }, body: {} },
444
+ { sessionID },
445
+ )
446
+ toast(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {})
447
+ }, job.timeoutMs)
448
+ }
449
+ const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
450
+ activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true })
451
+ if (compactionRuntime.isCompleted(sessionID, job.id)) {
452
+ await compactionRuntime.finalize(directory, client, sessionID)
453
+ return
454
+ }
455
+ markSessionStatus(sessionID, "busy")
456
+ await reschedule(busyRetryMs)
457
+ return
458
+ }
459
+
460
+ if (runNowRequested) delete job.runNowRequestedAt
461
+ job.watchTriggered = false
462
+ job.lastRunAt = now()
463
+ job.runCount = (job.runCount || 0) + 1
464
+ if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
465
+ job.enabled = false
466
+ await notifyJob(directory, job, "max_runs_reached")
467
+ }
468
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
469
+ await writeState(directory, sessionID, state)
470
+ await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
471
+ await toast(client, `Loop running: ${job.name || job.id}`, "info")
472
+
473
+ try {
474
+ const result = await fireAction(directory, client, sessionID, job)
475
+ if (!result.startsAssistantTurn) {
476
+ const fresh = await readState(directory, sessionID)
477
+ if (result.pause) {
478
+ fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
479
+ ...candidate,
480
+ paused: true,
481
+ failureCount: (candidate.failureCount || 0) + 1,
482
+ lastFailureReason: result.reason || "action_did_not_start",
483
+ } : candidate)
484
+ }
485
+ fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
486
+ await writeState(directory, sessionID, fresh)
487
+ await reschedule()
488
+ return
489
+ }
490
+
491
+ let timer
492
+ if (job.timeoutMs > 0) {
493
+ timer = setTimeout(() => {
494
+ fireSdk(
495
+ client,
496
+ "session.abort",
497
+ client.session.abort.bind(client.session),
498
+ { path: { id: sessionID }, body: {} },
499
+ { path: { sessionID }, body: {} },
500
+ { sessionID },
501
+ )
502
+ toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {})
503
+ }, job.timeoutMs)
504
+ }
505
+ const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
506
+ activeRuns.set(sessionID, {
507
+ jobId: job.id,
508
+ job,
509
+ startedAt: now(),
510
+ timer,
511
+ runToken,
512
+ compactionAction: result.compaction === true,
513
+ })
514
+ if (result.compaction && compactionRuntime.isCompleted(sessionID, job.id)) {
515
+ await compactionRuntime.finalize(directory, client, sessionID)
516
+ return
517
+ }
518
+ if (result.dispatch) {
519
+ void result.dispatch.catch((error) => {
520
+ recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error)
521
+ .catch((recoveryError) => log(client, "error", "dispatch recovery failed", { error: errorMessage(recoveryError) }))
522
+ })
523
+ }
524
+ markSessionStatus(sessionID, "busy")
525
+ await reschedule(busyRetryMs)
526
+ } catch (error) {
527
+ clearActiveRun(sessionID)
528
+ await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
529
+ await appendLoopLog(directory, "error", {
530
+ sessionID,
531
+ job: job?.name || job?.id,
532
+ error: error instanceof Error ? error.message : String(error),
533
+ })
534
+ await reschedule(busyRetryMs)
535
+ }
536
+ } finally {
537
+ runLocks.delete(sessionID)
538
+ }
539
+ }
540
+
541
+ return {
542
+ dueJobs,
543
+ clearActiveRun,
544
+ disposeSession,
545
+ recoverActiveDispatchFailure,
546
+ finalizeActiveRun,
547
+ fireAction,
548
+ maybeRunDueJobs,
549
+ sessionIsIdle,
550
+ updateSessionStatusFromEvent,
551
+ markSessionStatus,
552
+ clearSessionStatus,
553
+ noteLoopCompactionStarted: compactionRuntime.noteStarted,
554
+ noteLoopCompactionCompleted: compactionRuntime.noteCompleted,
555
+ getActiveRun: (sessionID) => activeRuns.get(sessionID),
556
+ isRunLocked: (sessionID) => runLocks.has(sessionID),
557
+ }
558
+ }
@@ -0,0 +1,77 @@
1
+ import { DEFAULT_GOAL_MAX_NO_PROGRESS, now as defaultNow } from "../core/args.js"
2
+ import { isGoalJob } from "../core/jobs.js"
3
+ import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
4
+ import { goalMadeMeaningfulProgress } from "./goal-runtime.js"
5
+
6
+ function requireFunction(value, name) {
7
+ if (typeof value !== "function") throw new TypeError(`createGoalExecutionPolicy requires ${name}`)
8
+ return value
9
+ }
10
+
11
+ export function createGoalExecutionPolicy(options = {}) {
12
+ const runShellCommand = requireFunction(options.runShellCommand, "runShellCommand")
13
+ const dangerousShell = requireFunction(options.dangerousShell, "dangerousShell")
14
+ const toast = requireFunction(options.toast, "toast")
15
+ const now = typeof options.now === "function" ? options.now : defaultNow
16
+ const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
17
+
18
+ async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
19
+ if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false) return job
20
+ const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS)
21
+ if (!Number.isFinite(limit) || limit <= 0) return job
22
+ if (goalMadeMeaningfulProgress(beforeJob, job)) {
23
+ job.noProgressCount = 0
24
+ job.lastProgressAt = now()
25
+ return job
26
+ }
27
+ job.noProgressCount = (job.noProgressCount || 0) + 1
28
+ job.lastNoProgressAt = now()
29
+ await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
30
+ if (job.noProgressCount >= limit) {
31
+ job.paused = true
32
+ job.goalNoProgressPausedAt = now()
33
+ job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`
34
+ await toast(client, job.goalNoProgressReason, "warning")
35
+ await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
36
+ }
37
+ return job
38
+ }
39
+
40
+ async function runGoalChecks(directory, sessionID, job, client) {
41
+ if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus)) return job
42
+ const results = []
43
+ for (const command of job.goalChecks) {
44
+ if (job.safe && dangerousShell(command)) {
45
+ results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." })
46
+ continue
47
+ }
48
+ const result = await runShellCommand(command, directory, job.timeoutMs || 300_000)
49
+ results.push({ command, code: result.code, output: (result.stdout + "\n" + result.stderr).slice(0, 1200) })
50
+ }
51
+ job.lastGoalCheckAt = now()
52
+ job.lastGoalChecks = results
53
+ const allPassed = results.length > 0 && results.every((item) => item.code === 0)
54
+ if (allPassed) {
55
+ job.goalChecksPassedAt = now()
56
+ job.failureCount = 0
57
+ await toast(client, "Goal checks passed.", "success")
58
+ if (job.goalCompleteWhenChecksPass) {
59
+ job.goalStatus = "completed"
60
+ job.enabled = false
61
+ job.paused = true
62
+ job.goalCompletedAt = now()
63
+ job.goalSummary = job.goalSummary || "All configured goal checks passed."
64
+ job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join("\n")
65
+ await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id })
66
+ }
67
+ } else {
68
+ job.failureCount = (job.failureCount || 0) + 1
69
+ job.lastVerifyFailure = results.map((item) => `${item.command}\nexit=${item.code}\n${item.output}`).join("\n\n").slice(0, 4000)
70
+ await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning")
71
+ }
72
+ await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) })
73
+ return job
74
+ }
75
+
76
+ return { runGoalChecks, applyGoalNoProgressGuard }
77
+ }