@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +11 -3
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +57 -8
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +32 -1
- package/src/source/opencode2/host-contract.js +26 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- 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
|
+
}
|