@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.
@@ -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
- const state = await readState(directory, sessionID)
150
- const job = (state.jobs || []).find((candidate) => candidate.id === jobId)
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
- job.failureCount = (job.failureCount || 0) + 1
153
- job.lastFailureReason = "dispatch_failed"
154
- job.lastDispatchFailure = message.slice(0, 4000)
155
- job.lastDispatchFailureAt = now()
156
- if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
157
- job.paused = true
158
- await notifyJob(directory, job, "dispatch_failed")
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", { sessionID, job: job?.name || jobId, error: message })
165
- await toast(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning")
166
- await scheduleDueWork(directory, client, sessionID, busyRetryMs)
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
- if (!await sessionIsIdle(client, sessionID, directory)) {
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
- if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
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
- const current = now()
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
  }