@bybrawe/opencode-loop 0.5.32 → 0.5.34
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 +17 -0
- package/README.md +197 -283
- package/commands/loop.md +1 -1
- package/package.json +3 -3
- package/scripts/action-dispatch-test.mjs +117 -0
- 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/run-admission-test.mjs +133 -0
- package/scripts/run-finalization-test.mjs +119 -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/src/index.js +916 -473
- package/src/source/core/continuation.js +20 -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/action-dispatch.js +108 -0
- package/src/source/runtime/companion-goal.js +37 -0
- package/src/source/runtime/executor.js +55 -228
- package/src/source/runtime/goal-evidence.js +39 -0
- package/src/source/runtime/goal-policy.js +1 -1
- package/src/source/runtime/goal-prompt.js +53 -0
- package/src/source/runtime/goal-report.js +52 -0
- package/src/source/runtime/goal-runtime.js +9 -141
- package/src/source/runtime/loop-diagnostics.js +45 -0
- package/src/source/runtime/run-admission.js +121 -0
- package/src/source/runtime/run-finalization.js +100 -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 +28 -4
|
@@ -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,121 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { now as defaultNow } from "../core/args.js"
|
|
3
|
+
import { pathExists as defaultPathExists, writeState as defaultWriteState } from "../core/state.js"
|
|
4
|
+
import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
|
|
5
|
+
import { toast as defaultToast } from "../opencode/host.js"
|
|
6
|
+
import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
|
|
7
|
+
import { dueJobs as sharedDueJobs } from "./schedule-policy.js"
|
|
8
|
+
|
|
9
|
+
function requireFunction(value, label) {
|
|
10
|
+
if (typeof value !== "function") throw new TypeError(`createRunAdmissionRuntime requires ${label}`)
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createRunAdmissionRuntime(options = {}) {
|
|
15
|
+
const untilReached = requireFunction(options.untilReached, "untilReached")
|
|
16
|
+
const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
|
|
17
|
+
|
|
18
|
+
const now = typeof options.now === "function" ? options.now : defaultNow
|
|
19
|
+
const pathExists = typeof options.pathExists === "function" ? options.pathExists : defaultPathExists
|
|
20
|
+
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
21
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
22
|
+
const runShellCommand = typeof options.runShellCommand === "function" ? options.runShellCommand : defaultRunShellCommand
|
|
23
|
+
const notifyJob = typeof options.notifyJob === "function" ? options.notifyJob : defaultNotifyJob
|
|
24
|
+
const toast = typeof options.toast === "function" ? options.toast : defaultToast
|
|
25
|
+
const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
|
|
26
|
+
|
|
27
|
+
function dueJobs(state, force = false) {
|
|
28
|
+
return sharedDueJobs(state, now(), force)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function reschedule(directory, client, sessionID) {
|
|
32
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function stopAndRemove(directory, client, sessionID, state, job, reason, message, logEvent) {
|
|
36
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
37
|
+
await writeState(directory, sessionID, state)
|
|
38
|
+
await notifyJob(directory, job, reason)
|
|
39
|
+
await toast(client, message, "success")
|
|
40
|
+
if (logEvent) await appendLoopLog(directory, logEvent, { sessionID, job: job.name || job.id })
|
|
41
|
+
await reschedule(directory, client, sessionID)
|
|
42
|
+
return { admitted: false, reason }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function admitJob(directory, client, sessionID, state, job) {
|
|
46
|
+
const runNowRequested = Number(job.runNowRequestedAt || 0) > 0
|
|
47
|
+
|
|
48
|
+
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
49
|
+
return await stopAndRemove(
|
|
50
|
+
directory,
|
|
51
|
+
client,
|
|
52
|
+
sessionID,
|
|
53
|
+
state,
|
|
54
|
+
job,
|
|
55
|
+
"max_runtime_reached",
|
|
56
|
+
`Loop stopped by --max-runtime: ${job.name || job.id}`,
|
|
57
|
+
"max-runtime",
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
62
|
+
return await stopAndRemove(
|
|
63
|
+
directory,
|
|
64
|
+
client,
|
|
65
|
+
sessionID,
|
|
66
|
+
state,
|
|
67
|
+
job,
|
|
68
|
+
"stop_file",
|
|
69
|
+
"Loop stopped by --stop-file: " + job.stopFile,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (await untilReached(directory, job)) {
|
|
74
|
+
return await stopAndRemove(
|
|
75
|
+
directory,
|
|
76
|
+
client,
|
|
77
|
+
sessionID,
|
|
78
|
+
state,
|
|
79
|
+
job,
|
|
80
|
+
"until_reached",
|
|
81
|
+
`Loop stopped by --until: ${job.until}`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (job.preflightCommand) {
|
|
86
|
+
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
87
|
+
if (runNowRequested) delete job.runNowRequestedAt
|
|
88
|
+
job.paused = true
|
|
89
|
+
await writeState(directory, sessionID, state)
|
|
90
|
+
await notifyJob(directory, job, "preflight_blocked")
|
|
91
|
+
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
92
|
+
await reschedule(directory, client, sessionID)
|
|
93
|
+
return { admitted: false, reason: "preflight_blocked" }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
97
|
+
await appendLoopLog(directory, "preflight", {
|
|
98
|
+
sessionID,
|
|
99
|
+
job: job.name || job.id,
|
|
100
|
+
command: job.preflightCommand,
|
|
101
|
+
code: preflight.code,
|
|
102
|
+
})
|
|
103
|
+
if (preflight.code !== 0) {
|
|
104
|
+
if (runNowRequested) delete job.runNowRequestedAt
|
|
105
|
+
job.paused = true
|
|
106
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
107
|
+
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
108
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
109
|
+
await writeState(directory, sessionID, state)
|
|
110
|
+
await notifyJob(directory, job, "preflight_failed")
|
|
111
|
+
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
112
|
+
await reschedule(directory, client, sessionID)
|
|
113
|
+
return { admitted: false, reason: "preflight_failed" }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { admitted: true, job, runNowRequested }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { dueJobs, admitJob }
|
|
121
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { now as defaultNow } from "../core/args.js"
|
|
2
|
+
import { isGoalJob } from "../core/jobs.js"
|
|
3
|
+
import { writeState as defaultWriteState } from "../core/state.js"
|
|
4
|
+
import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
|
|
5
|
+
import { toast as defaultToast } from "../opencode/host.js"
|
|
6
|
+
import { writeGoalReport as defaultWriteGoalReport } from "./goal-runtime.js"
|
|
7
|
+
import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
|
|
8
|
+
|
|
9
|
+
function requireFunction(value, label) {
|
|
10
|
+
if (typeof value !== "function") throw new TypeError(`createRunFinalizationRuntime requires ${label}`)
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createRunFinalizationRuntime(options = {}) {
|
|
15
|
+
const runGoalChecks = requireFunction(options.runGoalChecks, "runGoalChecks")
|
|
16
|
+
const applyGoalNoProgressGuard = requireFunction(options.applyGoalNoProgressGuard, "applyGoalNoProgressGuard")
|
|
17
|
+
const createCheckpoint = requireFunction(options.createCheckpoint, "createCheckpoint")
|
|
18
|
+
const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
|
|
19
|
+
|
|
20
|
+
const now = typeof options.now === "function" ? options.now : defaultNow
|
|
21
|
+
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
22
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
23
|
+
const runShellCommand = typeof options.runShellCommand === "function" ? options.runShellCommand : defaultRunShellCommand
|
|
24
|
+
const notifyJob = typeof options.notifyJob === "function" ? options.notifyJob : defaultNotifyJob
|
|
25
|
+
const toast = typeof options.toast === "function" ? options.toast : defaultToast
|
|
26
|
+
const writeGoalReport = typeof options.writeGoalReport === "function" ? options.writeGoalReport : defaultWriteGoalReport
|
|
27
|
+
const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
|
|
28
|
+
|
|
29
|
+
async function finalizeJob(directory, client, sessionID, state, job, previousJob) {
|
|
30
|
+
if (job.verifyCommand) {
|
|
31
|
+
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
32
|
+
job.lastVerifyAt = now()
|
|
33
|
+
job.lastVerifyCode = verify.code
|
|
34
|
+
if (verify.code === 0) {
|
|
35
|
+
job.failureCount = 0
|
|
36
|
+
job.lastVerifyFailure = ""
|
|
37
|
+
await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
|
|
38
|
+
} else {
|
|
39
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
40
|
+
job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
|
|
41
|
+
await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
|
|
42
|
+
if (job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) {
|
|
43
|
+
job.paused = true
|
|
44
|
+
await notifyJob(directory, job, "verify_failed")
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
await appendLoopLog(directory, "verify", {
|
|
48
|
+
sessionID,
|
|
49
|
+
job: job.name || job.id,
|
|
50
|
+
command: job.verifyCommand,
|
|
51
|
+
code: verify.code,
|
|
52
|
+
failures: job.failureCount || 0,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (job.postrunCommand) {
|
|
57
|
+
if (job.safe && dangerousShell(job.postrunCommand)) {
|
|
58
|
+
await appendLoopLog(directory, "postrun-blocked", {
|
|
59
|
+
sessionID,
|
|
60
|
+
job: job.name || job.id,
|
|
61
|
+
command: job.postrunCommand,
|
|
62
|
+
})
|
|
63
|
+
} else {
|
|
64
|
+
const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300_000)
|
|
65
|
+
job.lastPostrunCode = postrun.code
|
|
66
|
+
job.lastPostrunAt = now()
|
|
67
|
+
if (postrun.code !== 0) {
|
|
68
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
69
|
+
job.lastPostrunFailure = (job.postrunCommand + "\nexit=" + postrun.code + "\n" + postrun.stdout + "\n" + postrun.stderr).slice(0, 4000)
|
|
70
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
71
|
+
job.paused = true
|
|
72
|
+
await notifyJob(directory, job, "postrun_failed")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await appendLoopLog(directory, "postrun", {
|
|
76
|
+
sessionID,
|
|
77
|
+
job: job.name || job.id,
|
|
78
|
+
command: job.postrunCommand,
|
|
79
|
+
code: postrun.code,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isGoalJob(job)) {
|
|
85
|
+
job = await runGoalChecks(directory, sessionID, job, client)
|
|
86
|
+
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, previousJob)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
state.jobs = (state.jobs || [])
|
|
90
|
+
.map((candidate) => candidate.id === job.id ? job : candidate)
|
|
91
|
+
.filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
92
|
+
await writeState(directory, sessionID, state)
|
|
93
|
+
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
94
|
+
await createCheckpoint(directory, sessionID, job, client)
|
|
95
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
96
|
+
return job
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { finalizeJob }
|
|
100
|
+
}
|
|
@@ -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
|
}
|
|
@@ -130,6 +130,24 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
130
130
|
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
|
|
134
|
+
if (activeRuns.has(sessionID)) return false
|
|
135
|
+
if (liveType !== "busy" && liveType !== "retry") return false
|
|
136
|
+
// Never override the first fresh busy observation. Waiting at least one cache
|
|
137
|
+
// window gives the host time to expose a new user/assistant turn if it is real.
|
|
138
|
+
if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false
|
|
139
|
+
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 })
|
|
140
|
+
if (completion !== "completed") return false
|
|
141
|
+
markSessionStatus(sessionID, "idle")
|
|
142
|
+
await appendLoopLog(directory, "status-message-idle-recovery", {
|
|
143
|
+
sessionID,
|
|
144
|
+
staleStatus: liveType,
|
|
145
|
+
statusSeenAt: seenAt,
|
|
146
|
+
staleForMs: Math.max(0, now() - seenAt),
|
|
147
|
+
})
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
133
151
|
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
134
152
|
// OpenCode can briefly report an idle session while a long-running tool or
|
|
135
153
|
// subtask is still executing. Tool lifecycle hooks are the more specific
|
|
@@ -152,10 +170,16 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
152
170
|
|
|
153
171
|
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
154
172
|
if (live?.type) {
|
|
155
|
-
// Some OpenCode 1.15.x TUI builds can leave session.status at busy
|
|
156
|
-
// plugin
|
|
157
|
-
//
|
|
158
|
-
//
|
|
173
|
+
// Some OpenCode 1.15.x/1.18.x TUI builds can leave session.status at busy
|
|
174
|
+
// after a plugin command acknowledgement. If no Loop run exists yet, a
|
|
175
|
+
// completed assistant tail plus an already-stale busy observation is a
|
|
176
|
+
// stronger signal than the unchanged live status. A genuinely running turn
|
|
177
|
+
// has an unfinished assistant tail (or latest user message) and is never
|
|
178
|
+
// force-recovered by this path.
|
|
179
|
+
if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt)) return "idle"
|
|
180
|
+
|
|
181
|
+
// When a Loop-owned turn exists, use its exact start boundary so an older
|
|
182
|
+
// completed assistant message can never finalize a newer active run.
|
|
159
183
|
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
160
184
|
const active = activeRuns.get(sessionID)
|
|
161
185
|
if (active) {
|