@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +197 -283
  3. package/commands/loop.md +1 -1
  4. package/package.json +3 -3
  5. package/scripts/action-dispatch-test.mjs +117 -0
  6. package/scripts/comprehensive-test.mjs +1 -1
  7. package/scripts/host-loop-canary.mjs +8 -3
  8. package/scripts/loop-command-handlers-test.mjs +33 -4
  9. package/scripts/loop-registration-test.mjs +77 -3
  10. package/scripts/run-admission-test.mjs +133 -0
  11. package/scripts/run-finalization-test.mjs +119 -0
  12. package/scripts/schedule-policy-test.mjs +114 -0
  13. package/scripts/schedule-syntax-test.mjs +75 -0
  14. package/scripts/session-status-idle-recovery-test.mjs +44 -0
  15. package/src/index.js +916 -473
  16. package/src/source/core/continuation.js +20 -0
  17. package/src/source/core/jobs.js +3 -0
  18. package/src/source/core/schedule-syntax.js +93 -0
  19. package/src/source/opencode/loop-commands.js +39 -30
  20. package/src/source/opencode/loop-registration.js +40 -3
  21. package/src/source/runtime/action-dispatch.js +108 -0
  22. package/src/source/runtime/companion-goal.js +37 -0
  23. package/src/source/runtime/executor.js +55 -228
  24. package/src/source/runtime/goal-evidence.js +39 -0
  25. package/src/source/runtime/goal-policy.js +1 -1
  26. package/src/source/runtime/goal-prompt.js +53 -0
  27. package/src/source/runtime/goal-report.js +52 -0
  28. package/src/source/runtime/goal-runtime.js +9 -141
  29. package/src/source/runtime/loop-diagnostics.js +45 -0
  30. package/src/source/runtime/run-admission.js +121 -0
  31. package/src/source/runtime/run-finalization.js +100 -0
  32. package/src/source/runtime/schedule-policy.js +80 -0
  33. package/src/source/runtime/scheduler-diagnostics.js +30 -0
  34. package/src/source/runtime/scheduler.js +10 -23
  35. 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 after a
156
- // plugin-injected turn until the next user command touches the session.
157
- // When the only reason we still think the session is busy is our own stale
158
- // active-run guard, recover instead of waiting for another manual command.
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) {