@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/CHANGELOG.md +18 -0
- package/README.md +197 -283
- package/commands/loop.md +1 -1
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +1 -1
- package/scripts/host-loop-canary.mjs +8 -3
- package/scripts/loop-command-handlers-test.mjs +33 -4
- package/scripts/loop-registration-test.mjs +77 -3
- package/scripts/network-recovery-test.mjs +218 -0
- package/scripts/schedule-policy-test.mjs +114 -0
- package/scripts/schedule-syntax-test.mjs +75 -0
- package/scripts/session-status-idle-recovery-test.mjs +44 -0
- package/scripts/session-status-test.mjs +18 -4
- package/src/index.js +749 -126
- package/src/source/core/continuation.js +71 -0
- package/src/source/core/jobs.js +3 -0
- package/src/source/core/schedule-syntax.js +93 -0
- package/src/source/opencode/loop-commands.js +39 -30
- package/src/source/opencode/loop-registration.js +40 -3
- package/src/source/runtime/companion-goal.js +37 -0
- package/src/source/runtime/executor.js +145 -16
- package/src/source/runtime/loop-diagnostics.js +45 -0
- package/src/source/runtime/network-recovery.js +40 -0
- package/src/source/runtime/run-admission.js +2 -13
- package/src/source/runtime/run-finalization.js +28 -0
- package/src/source/runtime/schedule-policy.js +80 -0
- package/src/source/runtime/scheduler-diagnostics.js +30 -0
- package/src/source/runtime/scheduler.js +10 -23
- package/src/source/runtime/session-status.js +46 -23
- package/src/source/runtime/terminal-guard.js +56 -0
|
@@ -10,9 +10,12 @@ import { createCompactionRuntime } from "./compaction.js"
|
|
|
10
10
|
import { createActionDispatcher } from "./action-dispatch.js"
|
|
11
11
|
import { createRunFinalizationRuntime } from "./run-finalization.js"
|
|
12
12
|
import { createRunAdmissionRuntime } from "./run-admission.js"
|
|
13
|
+
import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"
|
|
13
14
|
|
|
14
15
|
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
15
16
|
const DEFAULT_BUSY_RETRY_MS = 5_000
|
|
17
|
+
const DEFAULT_PROVIDER_RETRY_WATCHDOG_MS = 2 * 60_000
|
|
18
|
+
const DEFAULT_NETWORK_RETRY_MAX_MS = 60_000
|
|
16
19
|
|
|
17
20
|
function requireFunction(value, label) {
|
|
18
21
|
if (typeof value !== "function") throw new TypeError(`createLoopExecutor requires ${label}`)
|
|
@@ -51,9 +54,16 @@ export function createLoopExecutor(options = {}) {
|
|
|
51
54
|
const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0
|
|
52
55
|
? Number(options.busyRetryMs)
|
|
53
56
|
: DEFAULT_BUSY_RETRY_MS
|
|
57
|
+
const providerRetryWatchdogMs = Number.isFinite(Number(options.providerRetryWatchdogMs)) && Number(options.providerRetryWatchdogMs) > 0
|
|
58
|
+
? Number(options.providerRetryWatchdogMs)
|
|
59
|
+
: DEFAULT_PROVIDER_RETRY_WATCHDOG_MS
|
|
60
|
+
const networkRetryMaxMs = Number.isFinite(Number(options.networkRetryMaxMs)) && Number(options.networkRetryMaxMs) > 0
|
|
61
|
+
? Number(options.networkRetryMaxMs)
|
|
62
|
+
: DEFAULT_NETWORK_RETRY_MAX_MS
|
|
54
63
|
|
|
55
64
|
const activeRuns = new Map()
|
|
56
65
|
const runLocks = new Map()
|
|
66
|
+
const retryRuns = new Map()
|
|
57
67
|
|
|
58
68
|
const statusRuntime = createSessionStatusRuntime({
|
|
59
69
|
activeRuns,
|
|
@@ -67,6 +77,7 @@ export function createLoopExecutor(options = {}) {
|
|
|
67
77
|
updateSessionStatusFromEvent,
|
|
68
78
|
staleActiveRun,
|
|
69
79
|
canFinalizeActiveRun,
|
|
80
|
+
sessionStatusType,
|
|
70
81
|
sessionIsIdle,
|
|
71
82
|
markSessionStatus,
|
|
72
83
|
clearSessionStatus,
|
|
@@ -129,6 +140,7 @@ export function createLoopExecutor(options = {}) {
|
|
|
129
140
|
if (active?.timer) clearTimeout(active.timer)
|
|
130
141
|
compactionRuntime.clearForActiveRun(sessionID, active)
|
|
131
142
|
activeRuns.delete(sessionID)
|
|
143
|
+
retryRuns.delete(sessionID)
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
function disposeSession(sessionID) {
|
|
@@ -138,32 +150,116 @@ export function createLoopExecutor(options = {}) {
|
|
|
138
150
|
clearSessionStatus(sessionID)
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
async function persistInfrastructureRefund(directory, sessionID, active, input = {}) {
|
|
154
|
+
const state = await readState(directory, sessionID)
|
|
155
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
156
|
+
if (!job) return { job: undefined, delayMs: busyRetryMs }
|
|
157
|
+
refundInfrastructureRun(job, {
|
|
158
|
+
runCount: active.job?.runCount,
|
|
159
|
+
previousLastRunAt: active.previousLastRunAt,
|
|
160
|
+
disabledByMaxRuns: active.disabledByMaxRuns,
|
|
161
|
+
}, {
|
|
162
|
+
reason: input.reason,
|
|
163
|
+
error: input.error,
|
|
164
|
+
now: now(),
|
|
165
|
+
})
|
|
166
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
167
|
+
await writeState(directory, sessionID, state)
|
|
168
|
+
const delayMs = networkRetryDelayMs(job.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs)
|
|
169
|
+
return { job, delayMs }
|
|
170
|
+
}
|
|
171
|
+
|
|
141
172
|
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
142
173
|
const active = activeRuns.get(sessionID)
|
|
143
174
|
if (!active || active.jobId !== jobId || active.runToken !== runToken) return false
|
|
144
175
|
|
|
176
|
+
const transient = isTransientNetworkError(error)
|
|
177
|
+
const snapshot = active
|
|
145
178
|
clearActiveRun(sessionID)
|
|
146
179
|
clearSessionStatus(sessionID)
|
|
147
180
|
|
|
148
181
|
const message = errorMessage(error)
|
|
149
|
-
|
|
150
|
-
|
|
182
|
+
let state = await readState(directory, sessionID)
|
|
183
|
+
let job = (state.jobs || []).find((candidate) => candidate.id === jobId)
|
|
184
|
+
let retryDelay = busyRetryMs
|
|
151
185
|
if (job) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
job
|
|
158
|
-
|
|
186
|
+
if (transient) {
|
|
187
|
+
const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
|
|
188
|
+
reason: "dispatch_failed_transient",
|
|
189
|
+
error,
|
|
190
|
+
})
|
|
191
|
+
job = refunded.job
|
|
192
|
+
retryDelay = refunded.delayMs
|
|
193
|
+
state = await readState(directory, sessionID)
|
|
194
|
+
} else {
|
|
195
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
196
|
+
job.lastFailureReason = "dispatch_failed"
|
|
197
|
+
job.lastDispatchFailure = message.slice(0, 4000)
|
|
198
|
+
job.lastDispatchFailureAt = now()
|
|
199
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
200
|
+
job.paused = true
|
|
201
|
+
await notifyJob(directory, job, "dispatch_failed")
|
|
202
|
+
}
|
|
203
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
204
|
+
await writeState(directory, sessionID, state)
|
|
159
205
|
}
|
|
160
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
161
|
-
await writeState(directory, sessionID, state)
|
|
162
206
|
}
|
|
163
207
|
|
|
164
|
-
await appendLoopLog(directory, "dispatch-error"
|
|
165
|
-
|
|
166
|
-
|
|
208
|
+
await appendLoopLog(directory, transient ? "network-dispatch-error" : "dispatch-error", {
|
|
209
|
+
sessionID,
|
|
210
|
+
job: job?.name || jobId,
|
|
211
|
+
error: message,
|
|
212
|
+
...(transient ? { retryInMs: retryDelay } : {}),
|
|
213
|
+
})
|
|
214
|
+
if (transient) {
|
|
215
|
+
await toast(client, `Loop network dispatch failed; retrying when the session recovers: ${message}`, "warning")
|
|
216
|
+
} else {
|
|
217
|
+
await toast(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning")
|
|
218
|
+
}
|
|
219
|
+
await scheduleDueWork(directory, client, sessionID, retryDelay)
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function recoverStuckProviderRetry(directory, client, sessionID) {
|
|
224
|
+
const active = activeRuns.get(sessionID)
|
|
225
|
+
if (!active || active.compactionOnly) {
|
|
226
|
+
retryRuns.delete(sessionID)
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
const current = retryRuns.get(sessionID)
|
|
230
|
+
if (!current || current.runToken !== active.runToken) {
|
|
231
|
+
retryRuns.set(sessionID, { runToken: active.runToken, since: now() })
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
if (now() - current.since < providerRetryWatchdogMs) return false
|
|
235
|
+
|
|
236
|
+
const snapshot = active
|
|
237
|
+
try {
|
|
238
|
+
if (client?.session?.abort) {
|
|
239
|
+
await fireSdk(
|
|
240
|
+
client,
|
|
241
|
+
"session.abort provider retry watchdog",
|
|
242
|
+
client.session.abort.bind(client.session),
|
|
243
|
+
{ path: { id: sessionID }, body: {} },
|
|
244
|
+
{ path: { sessionID }, body: {} },
|
|
245
|
+
{ sessionID },
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
} catch {}
|
|
249
|
+
clearActiveRun(sessionID)
|
|
250
|
+
clearSessionStatus(sessionID)
|
|
251
|
+
const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
|
|
252
|
+
reason: "provider_retry_watchdog",
|
|
253
|
+
error: `OpenCode session.status stayed retry for ${providerRetryWatchdogMs}ms`,
|
|
254
|
+
})
|
|
255
|
+
await appendLoopLog(directory, "provider-retry-recovery", {
|
|
256
|
+
sessionID,
|
|
257
|
+
job: refunded.job?.name || snapshot.jobId,
|
|
258
|
+
retryForMs: now() - current.since,
|
|
259
|
+
retryInMs: refunded.delayMs,
|
|
260
|
+
})
|
|
261
|
+
await toast(client, "Loop provider retry exceeded the watchdog; the Loop-owned turn was released and will retry with backoff.", "warning")
|
|
262
|
+
await scheduleDueWork(directory, client, sessionID, refunded.delayMs)
|
|
167
263
|
return true
|
|
168
264
|
}
|
|
169
265
|
|
|
@@ -217,13 +313,22 @@ export function createLoopExecutor(options = {}) {
|
|
|
217
313
|
}
|
|
218
314
|
runLocks.set(sessionID, now())
|
|
219
315
|
let job
|
|
316
|
+
let previousLastRunAt = 0
|
|
317
|
+
let disabledByMaxRuns = false
|
|
220
318
|
try {
|
|
221
319
|
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
|
|
222
|
-
|
|
320
|
+
const statusType = await sessionStatusType(client, sessionID, directory)
|
|
321
|
+
if (statusType !== "idle") {
|
|
322
|
+
if (statusType === "retry") {
|
|
323
|
+
if (await recoverStuckProviderRetry(directory, client, sessionID)) return
|
|
324
|
+
} else {
|
|
325
|
+
retryRuns.delete(sessionID)
|
|
326
|
+
}
|
|
223
327
|
if (runOptions.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
224
328
|
await reschedule(busyRetryMs)
|
|
225
329
|
return
|
|
226
330
|
}
|
|
331
|
+
retryRuns.delete(sessionID)
|
|
227
332
|
|
|
228
333
|
const active = activeRuns.get(sessionID)
|
|
229
334
|
const activeAge = active ? now() - (active.startedAt || 0) : 0
|
|
@@ -286,9 +391,11 @@ export function createLoopExecutor(options = {}) {
|
|
|
286
391
|
|
|
287
392
|
if (runNowRequested) delete job.runNowRequestedAt
|
|
288
393
|
job.watchTriggered = false
|
|
394
|
+
previousLastRunAt = Number(job.lastRunAt || 0)
|
|
289
395
|
job.lastRunAt = now()
|
|
290
396
|
job.runCount = (job.runCount || 0) + 1
|
|
291
|
-
|
|
397
|
+
disabledByMaxRuns = job.maxRuns > 0 && job.runCount >= job.maxRuns
|
|
398
|
+
if (disabledByMaxRuns) {
|
|
292
399
|
job.enabled = false
|
|
293
400
|
await notifyJob(directory, job, "max_runs_reached")
|
|
294
401
|
}
|
|
@@ -337,6 +444,8 @@ export function createLoopExecutor(options = {}) {
|
|
|
337
444
|
timer,
|
|
338
445
|
runToken,
|
|
339
446
|
compactionAction: result.compaction === true,
|
|
447
|
+
previousLastRunAt,
|
|
448
|
+
disabledByMaxRuns,
|
|
340
449
|
})
|
|
341
450
|
if (result.compaction && compactionRuntime.isCompleted(sessionID, job.id)) {
|
|
342
451
|
await compactionRuntime.finalize(directory, client, sessionID)
|
|
@@ -352,6 +461,24 @@ export function createLoopExecutor(options = {}) {
|
|
|
352
461
|
await reschedule(busyRetryMs)
|
|
353
462
|
} catch (error) {
|
|
354
463
|
clearActiveRun(sessionID)
|
|
464
|
+
if (isTransientNetworkError(error) && job) {
|
|
465
|
+
const stateAfterFailure = await readState(directory, sessionID)
|
|
466
|
+
const persisted = (stateAfterFailure.jobs || []).find((candidate) => candidate.id === job.id)
|
|
467
|
+
if (persisted) {
|
|
468
|
+
refundInfrastructureRun(persisted, {
|
|
469
|
+
runCount: job.runCount,
|
|
470
|
+
previousLastRunAt,
|
|
471
|
+
disabledByMaxRuns,
|
|
472
|
+
}, { reason: "action_dispatch_transient", error, now: now() })
|
|
473
|
+
stateAfterFailure.jobs = (stateAfterFailure.jobs || []).map((candidate) => candidate.id === persisted.id ? persisted : candidate)
|
|
474
|
+
await writeState(directory, sessionID, stateAfterFailure)
|
|
475
|
+
const delayMs = networkRetryDelayMs(persisted.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs)
|
|
476
|
+
await appendLoopLog(directory, "network-action-error", { sessionID, job: persisted.name || persisted.id, error: errorMessage(error), retryInMs: delayMs })
|
|
477
|
+
await toast(client, "Loop action hit a transient network failure; the logical run was refunded and will retry.", "warning")
|
|
478
|
+
await reschedule(delayMs)
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
}
|
|
355
482
|
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
356
483
|
await appendLoopLog(directory, "error", {
|
|
357
484
|
sessionID,
|
|
@@ -370,9 +497,11 @@ export function createLoopExecutor(options = {}) {
|
|
|
370
497
|
clearActiveRun,
|
|
371
498
|
disposeSession,
|
|
372
499
|
recoverActiveDispatchFailure,
|
|
500
|
+
recoverStuckProviderRetry,
|
|
373
501
|
finalizeActiveRun,
|
|
374
502
|
fireAction,
|
|
375
503
|
maybeRunDueJobs,
|
|
504
|
+
sessionStatusType,
|
|
376
505
|
sessionIsIdle,
|
|
377
506
|
updateSessionStatusFromEvent,
|
|
378
507
|
markSessionStatus,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { stateDir } from "../core/state.js"
|
|
4
|
+
import { scheduleDescription, scheduleState } from "./schedule-policy.js"
|
|
5
|
+
|
|
6
|
+
export function describeJobScheduling(job, current = Date.now()) {
|
|
7
|
+
return {
|
|
8
|
+
schedule: scheduleDescription(job),
|
|
9
|
+
state: scheduleState(job, current),
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function listPersistedLoopSessions(directory, currentSessionID) {
|
|
14
|
+
const root = stateDir(directory)
|
|
15
|
+
let names
|
|
16
|
+
try {
|
|
17
|
+
names = await fs.readdir(root)
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error?.code === "ENOENT") return []
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sessions = []
|
|
24
|
+
for (const name of names) {
|
|
25
|
+
if (!name.endsWith(".json")) continue
|
|
26
|
+
const sessionID = name.slice(0, -5)
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(await fs.readFile(path.join(root, name), "utf8"))
|
|
29
|
+
const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : []
|
|
30
|
+
const enabled = jobs.filter((job) => job?.enabled !== false && !job?.paused).length
|
|
31
|
+
const neverRan = jobs.filter((job) => Number(job?.runCount || 0) === 0).length
|
|
32
|
+
sessions.push({
|
|
33
|
+
sessionID,
|
|
34
|
+
current: sessionID === currentSessionID,
|
|
35
|
+
jobs: jobs.length,
|
|
36
|
+
enabled,
|
|
37
|
+
neverRan,
|
|
38
|
+
})
|
|
39
|
+
} catch {
|
|
40
|
+
sessions.push({ sessionID, current: sessionID === currentSessionID, jobs: 0, enabled: 0, neverRan: 0, corrupt: true })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return sessions.sort((a, b) => Number(b.current) - Number(a.current) || b.enabled - a.enabled || a.sessionID.localeCompare(b.sessionID))
|
|
45
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const TRANSIENT_NETWORK_PATTERNS = [
|
|
2
|
+
/\b(?:408|425|429|500|502|503|504|524)\b/i,
|
|
3
|
+
/rate[\s_-]?limit|too many requests|overloaded|service[\s_-]?unavailable|provider[_ -]?unavailable/i,
|
|
4
|
+
/terminated|fetch failed|failed to fetch|network[\s_-]?error|network connection lost/i,
|
|
5
|
+
/connection (?:error|refused|lost)|socket (?:hang up|connection was closed)|reset before headers/i,
|
|
6
|
+
/\b(?:enotfound|eai_again|econnrefused|econnreset|etimedout|ehostunreach|enetunreach|epipe)\b/i,
|
|
7
|
+
/\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i,
|
|
8
|
+
/\btimeout(?:error)?\b/i,
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
function errorText(value) {
|
|
12
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`
|
|
13
|
+
if (typeof value === "string") return value
|
|
14
|
+
try { return JSON.stringify(value) } catch { return String(value) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isTransientNetworkError(value) {
|
|
18
|
+
const text = errorText(value)
|
|
19
|
+
return TRANSIENT_NETWORK_PATTERNS.some((pattern) => pattern.test(text))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function networkRetryDelayMs(attempt, baseMs = 5_000, maxMs = 60_000) {
|
|
23
|
+
const safeAttempt = Math.max(1, Math.floor(Number(attempt) || 1))
|
|
24
|
+
const safeBase = Math.max(1, Math.floor(Number(baseMs) || 5_000))
|
|
25
|
+
const safeMax = Math.max(safeBase, Math.floor(Number(maxMs) || 60_000))
|
|
26
|
+
return Math.min(safeMax, safeBase * (2 ** Math.min(8, safeAttempt - 1)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function refundInfrastructureRun(job, snapshot = {}, input = {}) {
|
|
30
|
+
const chargedCount = Number(snapshot.runCount ?? job.runCount ?? 0)
|
|
31
|
+
const currentCount = Number(job.runCount || 0)
|
|
32
|
+
if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1)
|
|
33
|
+
if (Number.isFinite(Number(snapshot.previousLastRunAt))) job.lastRunAt = Number(snapshot.previousLastRunAt)
|
|
34
|
+
if (snapshot.disabledByMaxRuns && job.maxRuns > 0 && job.runCount < job.maxRuns) job.enabled = true
|
|
35
|
+
job.infrastructureFailureCount = (job.infrastructureFailureCount || 0) + 1
|
|
36
|
+
job.lastInfrastructureFailure = String(input.reason || "transient_network_failure").slice(0, 120)
|
|
37
|
+
job.lastInfrastructureError = errorText(input.error).slice(0, 4000)
|
|
38
|
+
job.lastInfrastructureFailureAt = Number(input.now || Date.now())
|
|
39
|
+
return job
|
|
40
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import { now as defaultNow } from "../core/args.js"
|
|
3
|
-
import { isGoalJob } from "../core/jobs.js"
|
|
4
3
|
import { pathExists as defaultPathExists, writeState as defaultWriteState } from "../core/state.js"
|
|
5
4
|
import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
|
|
6
5
|
import { toast as defaultToast } from "../opencode/host.js"
|
|
7
6
|
import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
|
|
7
|
+
import { dueJobs as sharedDueJobs } from "./schedule-policy.js"
|
|
8
8
|
|
|
9
9
|
function requireFunction(value, label) {
|
|
10
10
|
if (typeof value !== "function") throw new TypeError(`createRunAdmissionRuntime requires ${label}`)
|
|
@@ -25,18 +25,7 @@ export function createRunAdmissionRuntime(options = {}) {
|
|
|
25
25
|
const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
|
|
26
26
|
|
|
27
27
|
function dueJobs(state, force = false) {
|
|
28
|
-
|
|
29
|
-
const due = (state.jobs || []).filter((job) => {
|
|
30
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
|
|
31
|
-
if (!job.enabled || job.paused) return false
|
|
32
|
-
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
|
|
33
|
-
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
|
|
34
|
-
if (Number(job.runNowRequestedAt || 0) > 0) return true
|
|
35
|
-
if (force) return true
|
|
36
|
-
if (job.watchPaths?.length) return job.watchTriggered === true
|
|
37
|
-
return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
|
|
38
|
-
})
|
|
39
|
-
return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0))
|
|
28
|
+
return sharedDueJobs(state, now(), force)
|
|
40
29
|
}
|
|
41
30
|
|
|
42
31
|
async function reschedule(directory, client, sessionID) {
|
|
@@ -5,6 +5,7 @@ import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShe
|
|
|
5
5
|
import { toast as defaultToast } from "../opencode/host.js"
|
|
6
6
|
import { writeGoalReport as defaultWriteGoalReport } from "./goal-runtime.js"
|
|
7
7
|
import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
|
|
8
|
+
import { applyTerminalContinuationGuard as defaultApplyTerminalContinuationGuard } from "./terminal-guard.js"
|
|
8
9
|
|
|
9
10
|
function requireFunction(value, label) {
|
|
10
11
|
if (typeof value !== "function") throw new TypeError(`createRunFinalizationRuntime requires ${label}`)
|
|
@@ -25,8 +26,22 @@ export function createRunFinalizationRuntime(options = {}) {
|
|
|
25
26
|
const toast = typeof options.toast === "function" ? options.toast : defaultToast
|
|
26
27
|
const writeGoalReport = typeof options.writeGoalReport === "function" ? options.writeGoalReport : defaultWriteGoalReport
|
|
27
28
|
const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
|
|
29
|
+
const applyTerminalContinuationGuard = typeof options.applyTerminalContinuationGuard === "function"
|
|
30
|
+
? options.applyTerminalContinuationGuard
|
|
31
|
+
: defaultApplyTerminalContinuationGuard
|
|
28
32
|
|
|
29
33
|
async function finalizeJob(directory, client, sessionID, state, job, previousJob) {
|
|
34
|
+
// Infrastructure backoff is consecutive, not lifetime. Reaching normal
|
|
35
|
+
// finalization proves a real Loop-owned turn completed, so the next future
|
|
36
|
+
// outage should restart from the shortest backoff rather than inheriting an
|
|
37
|
+
// old 60s cap from unrelated historical network failures.
|
|
38
|
+
if (job.infrastructureFailureCount) {
|
|
39
|
+
job.infrastructureFailureCount = 0
|
|
40
|
+
job.lastInfrastructureFailure = ""
|
|
41
|
+
job.lastInfrastructureError = ""
|
|
42
|
+
job.lastInfrastructureFailureAt = 0
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
if (job.verifyCommand) {
|
|
31
46
|
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
32
47
|
job.lastVerifyAt = now()
|
|
@@ -86,6 +101,19 @@ export function createRunFinalizationRuntime(options = {}) {
|
|
|
86
101
|
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, previousJob)
|
|
87
102
|
}
|
|
88
103
|
|
|
104
|
+
const terminal = await applyTerminalContinuationGuard(directory, client, sessionID, job)
|
|
105
|
+
job = terminal.job
|
|
106
|
+
if (terminal.pausedNow) {
|
|
107
|
+
await appendLoopLog(directory, "terminal-no-work", {
|
|
108
|
+
sessionID,
|
|
109
|
+
job: job.name || job.id,
|
|
110
|
+
count: job.terminalNoWorkCount,
|
|
111
|
+
summary: String(terminal.text || "").slice(0, 1000),
|
|
112
|
+
})
|
|
113
|
+
await notifyJob(directory, job, "terminal_no_work")
|
|
114
|
+
await toast(client, "Loop paused: completion-bounded task reported complete with no work remaining twice.", "success")
|
|
115
|
+
}
|
|
116
|
+
|
|
89
117
|
state.jobs = (state.jobs || [])
|
|
90
118
|
.map((candidate) => candidate.id === job.id ? job : candidate)
|
|
91
119
|
.filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { durationToText } from "../core/args.js"
|
|
2
|
+
import { isGoalJob } from "../core/jobs.js"
|
|
3
|
+
|
|
4
|
+
const TERMINAL_GOAL_STATUSES = new Set(["completed", "blocked", "cleared"])
|
|
5
|
+
|
|
6
|
+
export function inferredScheduleMode(job) {
|
|
7
|
+
const explicit = String(job?.scheduleMode || "").toLowerCase()
|
|
8
|
+
if (["idle", "interval", "once", "watch"].includes(explicit)) return explicit
|
|
9
|
+
if (job?.watchPaths?.length) return "watch"
|
|
10
|
+
if (Number(job?.maxRuns || 0) === 1 && job?.immediate === false && Number(job?.intervalMs || 0) > 0) return "once"
|
|
11
|
+
return Number(job?.intervalMs || 0) === 0 ? "idle" : "interval"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function jobRunnable(job) {
|
|
15
|
+
if (!job) return false
|
|
16
|
+
if (isGoalJob(job) && TERMINAL_GOAL_STATUSES.has(job.goalStatus)) return false
|
|
17
|
+
if (!job.enabled || job.paused) return false
|
|
18
|
+
if (Number(job.maxRuns || 0) > 0 && Number(job.runCount || 0) >= Number(job.maxRuns || 0)) return false
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function jobDueAt(job, current = Date.now()) {
|
|
23
|
+
if (!jobRunnable(job)) return Infinity
|
|
24
|
+
if (Number(job.runNowRequestedAt || 0) > 0) return current
|
|
25
|
+
|
|
26
|
+
const created = Date.parse(job.createdAt || "")
|
|
27
|
+
if (Number(job.maxRuntimeMs || 0) > 0 && Number.isFinite(created) && current - created >= Number(job.maxRuntimeMs || 0)) return current
|
|
28
|
+
|
|
29
|
+
if (job.watchPaths?.length) return job.watchTriggered === true ? current : Infinity
|
|
30
|
+
|
|
31
|
+
const intervalMs = Number(job.intervalMs || 0)
|
|
32
|
+
if (intervalMs === 0) return current
|
|
33
|
+
|
|
34
|
+
const lastRunAt = Number(job.lastRunAt || 0)
|
|
35
|
+
if (!lastRunAt) {
|
|
36
|
+
if (job.immediate === false) return (Number.isFinite(created) ? created : current) + intervalMs
|
|
37
|
+
return current
|
|
38
|
+
}
|
|
39
|
+
return lastRunAt + intervalMs
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function jobIsDue(job, current = Date.now(), force = false) {
|
|
43
|
+
if (!jobRunnable(job)) return false
|
|
44
|
+
if (force) return true
|
|
45
|
+
return jobDueAt(job, current) <= current
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function dueJobs(state, current = Date.now(), force = false) {
|
|
49
|
+
return (state?.jobs || [])
|
|
50
|
+
.filter((job) => jobIsDue(job, current, force))
|
|
51
|
+
.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function nextDueDelay(state, current = Date.now()) {
|
|
55
|
+
let soonest = Infinity
|
|
56
|
+
for (const job of state?.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
57
|
+
if (!Number.isFinite(soonest)) return Infinity
|
|
58
|
+
return Math.max(0, soonest - current)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function scheduleDescription(job) {
|
|
62
|
+
const mode = inferredScheduleMode(job)
|
|
63
|
+
const intervalMs = Number(job?.intervalMs || 0)
|
|
64
|
+
if (mode === "idle") return "every idle"
|
|
65
|
+
if (mode === "watch") return `on watch: ${(job.watchPaths || []).join(", ")}`
|
|
66
|
+
if (mode === "once") return intervalMs > 0 ? `once after ${durationToText(intervalMs)}` : "once on next idle"
|
|
67
|
+
if (job?.immediate === false) return `every ${durationToText(intervalMs)}, first after ${durationToText(intervalMs)}`
|
|
68
|
+
return `every ${durationToText(intervalMs)}, starts on next idle`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function scheduleState(job, current = Date.now()) {
|
|
72
|
+
if (!job?.enabled) return "stopped"
|
|
73
|
+
if (job?.paused) return "paused"
|
|
74
|
+
if (Number(job?.runNowRequestedAt || 0) > 0) return "due now; waiting for idle"
|
|
75
|
+
const mode = inferredScheduleMode(job)
|
|
76
|
+
const dueAt = jobDueAt(job, current)
|
|
77
|
+
if (!Number.isFinite(dueAt)) return mode === "watch" ? "waiting for watched change" : "not scheduled"
|
|
78
|
+
if (dueAt <= current) return mode === "idle" ? "waiting for idle" : "due; waiting for idle"
|
|
79
|
+
return `due in ${durationToText(dueAt - current)}`
|
|
80
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30_000
|
|
2
|
+
|
|
3
|
+
export function createSchedulerDiagnostics(options = {}) {
|
|
4
|
+
const now = typeof options.now === "function" ? options.now : Date.now
|
|
5
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {}
|
|
6
|
+
const configured = Number(options.throttleMs)
|
|
7
|
+
const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS
|
|
8
|
+
const lastLogged = new Map()
|
|
9
|
+
|
|
10
|
+
async function logDeferral(directory, sessionID, reason, extra = {}) {
|
|
11
|
+
const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`
|
|
12
|
+
const current = now()
|
|
13
|
+
const previous = Number(lastLogged.get(key) || 0)
|
|
14
|
+
if (previous > 0 && current - previous < throttleMs) return false
|
|
15
|
+
lastLogged.set(key, current)
|
|
16
|
+
await appendLoopLog(directory, "deferred", {
|
|
17
|
+
sessionID,
|
|
18
|
+
reason,
|
|
19
|
+
...extra,
|
|
20
|
+
})
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clearSession(sessionID) {
|
|
25
|
+
const prefix = `${sessionID || "unknown"}:`
|
|
26
|
+
for (const key of lastLogged.keys()) if (key.startsWith(prefix)) lastLogged.delete(key)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { logDeferral, clearSession }
|
|
30
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { now } from "../core/args.js"
|
|
2
2
|
import { isGoalJob } from "../core/jobs.js"
|
|
3
3
|
import { readState } from "../core/state.js"
|
|
4
|
+
import { createSchedulerDiagnostics } from "./scheduler-diagnostics.js"
|
|
5
|
+
import { jobDueAt, nextDueDelay } from "./schedule-policy.js"
|
|
6
|
+
|
|
7
|
+
export { jobDueAt, nextDueDelay }
|
|
4
8
|
|
|
5
9
|
const DEFAULT_IDLE_DEBOUNCE_MS = 1_200
|
|
6
10
|
const DEFAULT_BUSY_RETRY_MS = 5_000
|
|
@@ -9,29 +13,6 @@ const DEFAULT_MAX_DUE_TIMER_MS = 2_147_000_000
|
|
|
9
13
|
const DEFAULT_HEARTBEAT_MS = 2_500
|
|
10
14
|
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000
|
|
11
15
|
|
|
12
|
-
export function jobDueAt(job, current = now()) {
|
|
13
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
|
|
14
|
-
if (!job.enabled || job.paused) return Infinity
|
|
15
|
-
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
|
|
16
|
-
if (Number(job.runNowRequestedAt || 0) > 0) return current
|
|
17
|
-
if (job.watchPaths?.length) return Infinity
|
|
18
|
-
const created = Date.parse(job.createdAt || "")
|
|
19
|
-
if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs) return current
|
|
20
|
-
if (job.intervalMs === 0) return current
|
|
21
|
-
if (!job.lastRunAt) {
|
|
22
|
-
if (job.immediate === false) return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0)
|
|
23
|
-
return current
|
|
24
|
-
}
|
|
25
|
-
return job.lastRunAt + (job.intervalMs || 0)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function nextDueDelay(state, current = now()) {
|
|
29
|
-
let soonest = Infinity
|
|
30
|
-
for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
31
|
-
if (!Number.isFinite(soonest)) return Infinity
|
|
32
|
-
return Math.max(0, soonest - current)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
16
|
export function createSchedulerRuntime(options = {}) {
|
|
36
17
|
const idleTimers = new Map()
|
|
37
18
|
const dueTimers = new Map()
|
|
@@ -55,6 +36,7 @@ export function createSchedulerRuntime(options = {}) {
|
|
|
55
36
|
const errorMessage = (error) => options.errorMessage ? options.errorMessage(error) : (error instanceof Error ? error.message : String(error || "unknown error"))
|
|
56
37
|
const appendLog = async (directory, event, extra) => { if (options.appendLoopLog) await options.appendLoopLog(directory, event, extra) }
|
|
57
38
|
const toast = async (client, message, level) => { if (options.toast) await options.toast(client, message, level) }
|
|
39
|
+
const diagnostics = createSchedulerDiagnostics({ appendLoopLog: appendLog, now: clock, throttleMs: options.deferralLogThrottleMs })
|
|
58
40
|
|
|
59
41
|
function stopHeartbeatIfIdle() {
|
|
60
42
|
if (!knownSessions.size && heartbeatTimer) {
|
|
@@ -107,11 +89,13 @@ export function createSchedulerRuntime(options = {}) {
|
|
|
107
89
|
Promise.resolve()
|
|
108
90
|
.then(async () => {
|
|
109
91
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
92
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "idle", retryMs: busyRetryMs })
|
|
110
93
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
111
94
|
return
|
|
112
95
|
}
|
|
113
96
|
await options.finalizeActiveRun?.(directory, client, sessionID)
|
|
114
97
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
98
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "idle", retryMs: busyRetryMs })
|
|
115
99
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
116
100
|
return
|
|
117
101
|
}
|
|
@@ -164,11 +148,13 @@ export function createSchedulerRuntime(options = {}) {
|
|
|
164
148
|
Promise.resolve()
|
|
165
149
|
.then(async () => {
|
|
166
150
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
151
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "due", retryMs: busyRetryMs })
|
|
167
152
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
168
153
|
return
|
|
169
154
|
}
|
|
170
155
|
await options.finalizeActiveRun?.(directory, client, sessionID)
|
|
171
156
|
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
157
|
+
await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "due", retryMs: busyRetryMs })
|
|
172
158
|
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
173
159
|
return
|
|
174
160
|
}
|
|
@@ -193,6 +179,7 @@ export function createSchedulerRuntime(options = {}) {
|
|
|
193
179
|
cancelIdleWork(sessionID)
|
|
194
180
|
cancelDueWork(sessionID)
|
|
195
181
|
stopWatchdog(sessionID)
|
|
182
|
+
diagnostics.clearSession(sessionID)
|
|
196
183
|
knownSessions.delete(sessionID)
|
|
197
184
|
stopHeartbeatIfIdle()
|
|
198
185
|
}
|