@bybrawe/opencode-loop 0.5.33 → 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.
@@ -0,0 +1,20 @@
1
+ const CONTINUATION_SHORTHANDS = new Set([
2
+ "continue",
3
+ "continue.",
4
+ "continue working",
5
+ "keep going",
6
+ "go on",
7
+ "devam",
8
+ "devam et",
9
+ "devam et.",
10
+ "devam et bakalım",
11
+ ])
12
+
13
+ export function isContinuationShorthand(value) {
14
+ return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "))
15
+ }
16
+
17
+ export function continuationProjectInstruction(value) {
18
+ if (!isContinuationShorthand(value)) return ""
19
+ return "Treat this as continuation of the current project and conversation, not a fresh task. Inspect the repository state, relevant files, TODO/progress notes, recent changes, and git status as needed to identify the next unfinished step. Continue from existing work, do not redo completed work, and verify meaningful changes when practical."
20
+ }
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_GOAL_MAX_NO_PROGRESS, parseDuration, durationToText } from "./args.js"
2
+ import { continuationProjectInstruction } from "./continuation.js"
2
3
 
3
4
  export function presetDefaults(name) {
4
5
  // parseLoopArgs owns duration/flag/action parsing. Presets only provide real
@@ -54,6 +55,8 @@ export function actionKind(action, job = {}) {
54
55
 
55
56
  export function decoratePrompt(job) {
56
57
  const additions = []
58
+ const continuation = continuationProjectInstruction(job.action)
59
+ if (continuation) additions.push(continuation)
57
60
  if (job.progressFile) additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`)
58
61
  if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
59
62
  if (job.askNever) additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.")
@@ -0,0 +1,93 @@
1
+ import { parseDuration } from "./args.js"
2
+
3
+ function removeBooleanFlag(input, flag) {
4
+ const pattern = new RegExp(`(^|\\s)${flag.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}(?=\\s|$)`, "i")
5
+ const found = pattern.test(input)
6
+ return {
7
+ found,
8
+ value: String(input || "").replace(pattern, " ").replace(/\s+/g, " ").trim(),
9
+ }
10
+ }
11
+
12
+ function firstToken(input) {
13
+ const match = String(input || "").trim().match(/^(\S+)(?:\s+([\s\S]*))?$/)
14
+ return match ? { token: match[1], rest: String(match[2] || "").trim() } : { token: "", rest: "" }
15
+ }
16
+
17
+ function inferredMode(intervalMs, maxRuns) {
18
+ if (Number(maxRuns || 0) === 1 && Number(intervalMs || 0) > 0) return "once"
19
+ return Number(intervalMs || 0) === 0 ? "idle" : "interval"
20
+ }
21
+
22
+ /**
23
+ * Normalize the human-facing /loop schedule grammar before the legacy flag parser.
24
+ *
25
+ * Supported forms:
26
+ * /loop <prompt> -> every idle, unlimited
27
+ * /loop idle <prompt> -> every idle, unlimited
28
+ * /loop every 5m <prompt> -> recurring, first run after 5m
29
+ * /loop after 5m <prompt> -> one shot after 5m
30
+ * /loop in 5m <prompt> -> alias for `after`
31
+ * /loop 5m <prompt> -> legacy compact form (starts now unless --no-now)
32
+ */
33
+ export function normalizeLoopScheduleArgs(raw, defaults = {}) {
34
+ const overlap = removeBooleanFlag(String(raw || "").trim(), "--allow-goal-overlap")
35
+ let input = overlap.value
36
+ const nextDefaults = { ...defaults }
37
+ let scheduleMode = defaults.scheduleMode
38
+ let scheduleSyntax = "legacy"
39
+
40
+ const first = firstToken(input)
41
+ const keyword = first.token.toLowerCase()
42
+
43
+ if (keyword === "idle") {
44
+ nextDefaults.intervalMs = 0
45
+ nextDefaults.immediate = true
46
+ scheduleMode = "idle"
47
+ scheduleSyntax = "idle"
48
+ input = first.rest
49
+ } else if (keyword === "every" || keyword === "after" || keyword === "in") {
50
+ const duration = firstToken(first.rest)
51
+ const intervalMs = parseDuration(duration.token)
52
+ if (intervalMs === null) {
53
+ return {
54
+ ok: false,
55
+ error: `Invalid ${keyword} schedule. Example: /loop ${keyword === "every" ? "every" : "after"} 5m continue the project`,
56
+ }
57
+ }
58
+ nextDefaults.intervalMs = intervalMs
59
+ nextDefaults.immediate = false
60
+ input = duration.rest
61
+ if (keyword === "every") {
62
+ scheduleMode = intervalMs === 0 ? "idle" : "interval"
63
+ scheduleSyntax = "every"
64
+ } else {
65
+ nextDefaults.maxRuns = 1
66
+ scheduleMode = "once"
67
+ scheduleSyntax = "after"
68
+ }
69
+ } else {
70
+ const duration = parseDuration(first.token)
71
+ if (duration !== null) {
72
+ scheduleMode = duration === 0 ? "idle" : "interval"
73
+ } else if (nextDefaults.intervalMs === undefined || nextDefaults.intervalMs === null) {
74
+ // Plain /loop text is the ergonomic auto-continue form. Flags at the front
75
+ // are also accepted because the lower-level parser now receives an idle default.
76
+ nextDefaults.intervalMs = 0
77
+ nextDefaults.immediate = nextDefaults.immediate ?? true
78
+ scheduleMode = "idle"
79
+ scheduleSyntax = "idle-shorthand"
80
+ } else {
81
+ scheduleMode = scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns)
82
+ }
83
+ }
84
+
85
+ return {
86
+ ok: true,
87
+ args: input,
88
+ defaults: nextDefaults,
89
+ scheduleMode: scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns),
90
+ scheduleSyntax,
91
+ allowGoalOverlap: overlap.found || defaults.allowGoalOverlap === true,
92
+ }
93
+ }
@@ -1,9 +1,11 @@
1
1
  import { promises as fs } from "node:fs"
2
2
  import path from "node:path"
3
- import { now as defaultNow, durationToText } from "../core/args.js"
3
+ import { now as defaultNow } from "../core/args.js"
4
4
  import { jobLabel, matchJob, isGoalJob, goalStatusText } from "../core/jobs.js"
5
5
  import { stateDir, pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState, removeState as defaultRemoveState } from "../core/state.js"
6
6
  import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
7
+ import { dedicatedGoalSummary, findDedicatedGoalForSession as defaultFindDedicatedGoalForSession } from "../runtime/companion-goal.js"
8
+ import { describeJobScheduling, listPersistedLoopSessions as defaultListPersistedLoopSessions } from "../runtime/loop-diagnostics.js"
7
9
 
8
10
  const SERVICE = "opencode-loop"
9
11
  const DEFAULT_PROGRESS_MD = `# Progress
@@ -54,6 +56,8 @@ export function createLoopCommandHandlers(options = {}) {
54
56
  const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
55
57
  const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs.readFile(...args)
56
58
  const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs.writeFile(...args)
59
+ const listPersistedLoopSessions = typeof options.listPersistedLoopSessions === "function" ? options.listPersistedLoopSessions : defaultListPersistedLoopSessions
60
+ const findDedicatedGoalForSession = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : defaultFindDedicatedGoalForSession
57
61
  const runtimeVersion = options.runtimeVersion || process.version
58
62
  const runtimePlatform = options.runtimePlatform || process.platform
59
63
 
@@ -88,21 +92,11 @@ export function createLoopCommandHandlers(options = {}) {
88
92
  async function statusLoop(directory, client, sessionID) {
89
93
  const state = await readState(directory, sessionID)
90
94
  const jobs = state.jobs || []
95
+ const current = now()
91
96
  const lines = jobs.length ? jobs.map((job, index) => {
92
- const current = now()
93
- const intervalMs = Number(job.intervalMs || 0)
94
- const lastRunAt = Number(job.lastRunAt || 0)
95
- const createdAt = Date.parse(job.createdAt || "")
96
- const dueAt = Number(job.runNowRequestedAt || 0) > 0
97
- ? current
98
- : lastRunAt > 0
99
- ? lastRunAt + intervalMs
100
- : job.immediate === false && Number.isFinite(createdAt)
101
- ? createdAt + intervalMs
102
- : current
103
- const dueIn = Math.max(0, dueAt - current)
97
+ const scheduling = describeJobScheduling(job, current)
104
98
  const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", Number(job.runNowRequestedAt || 0) > 0 ? "run-now" : undefined, job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",")
105
- return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
99
+ return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | schedule=${scheduling.schedule} | state=${scheduling.state} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | ${flags}`
106
100
  }) : ["No active loop jobs."]
107
101
  await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
108
102
  await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n"))
@@ -117,18 +111,20 @@ export function createLoopCommandHandlers(options = {}) {
117
111
  async function helpLoop(client, sessionID) {
118
112
  await say(client, sessionID, [
119
113
  "OpenCode Loop help:",
120
- "/loop 0s <prompt> Claude Code style auto-continue",
121
- "/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
122
- "/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
123
- "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
124
- "/loop-shell 10m npm test shell loop, waits for idle",
125
- "/loop-goal finish the feature and keep tests green experimental persistent goal mode",
126
- "/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
127
- "/loop-goal status | pause | resume | clear manage experimental goals",
128
- "/loop 200m --command /compact same as command loop",
129
- "/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
130
- "/loop 0s --prompt-file loop-prompt.md load prompt from a file",
131
- "/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
114
+ "/loop continue the project auto-continue forever whenever the session becomes idle",
115
+ "/loop idle continue the project explicit form of the same idle loop",
116
+ "/loop every 5m continue the project recurring timer; first run after 5m, always waits for idle",
117
+ "/loop after 5m continue the project one-shot delayed prompt; runs once when 5m has passed and session is idle",
118
+ "/loop 5m continue the project legacy compact form: starts on next idle, then every 5m",
119
+ "/loop 5m --no-now continue the project legacy recurring form with first run delayed 5m",
120
+ "/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
121
+ "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
122
+ "/loop-shell 10m npm test shell loop, waits for idle",
123
+ "/loop-goal finish the feature and keep tests green experimental persistent goal mode",
124
+ "/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
125
+ "/loop --prompt-file loop-prompt.md idle loop loading its prompt from a file",
126
+ "/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
127
+ "Prompt-producing /loop jobs are blocked while dedicated /goal owns the same session; use another session or --allow-goal-overlap only intentionally.",
132
128
  "/loop-doctor | /loop-init | /loop-export",
133
129
  ].join("\n"))
134
130
  }
@@ -154,17 +150,30 @@ export function createLoopCommandHandlers(options = {}) {
154
150
 
155
151
  async function doctorLoop(directory, client, sessionID) {
156
152
  const state = await readState(directory, sessionID)
157
- await say(client, sessionID, [
153
+ const persisted = await listPersistedLoopSessions(directory, sessionID)
154
+ const otherSessions = persisted.filter((entry) => !entry.current && entry.enabled > 0)
155
+ const dedicatedGoal = await findDedicatedGoalForSession(directory, sessionID)
156
+ const lines = [
158
157
  "OpenCode Loop doctor:",
159
158
  `- plugin: ${SERVICE}`,
160
159
  `- project directory: ${directory}`,
161
160
  `- state directory: ${stateDir(directory)}`,
162
- `- active jobs: ${(state.jobs || []).length}`,
161
+ `- current session: ${sessionID}`,
162
+ `- current-session jobs: ${(state.jobs || []).length}`,
163
+ `- dedicated /goal: ${dedicatedGoalSummary(dedicatedGoal)}`,
164
+ `- other persisted sessions with enabled jobs: ${otherSessions.length}`,
163
165
  `- node: ${runtimeVersion}`,
164
166
  `- platform: ${runtimePlatform}`,
165
- "- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
167
+ "- smoke test: /loop --max-runs 1 --dry-run continue from progress.md",
168
+ "- delayed smoke test: /loop after 5m --dry-run continue from progress.md",
169
+ "- recurring smoke test: /loop every 5m --dry-run continue from progress.md",
166
170
  "- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
167
- ].join("\n"))
171
+ ]
172
+ for (const entry of otherSessions.slice(0, 8)) {
173
+ lines.push(`- other session ${entry.sessionID}: jobs=${entry.jobs}, enabled=${entry.enabled}, never-ran=${entry.neverRan}`)
174
+ }
175
+ if (otherSessions.length > 8) lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`)
176
+ await say(client, sessionID, lines.join("\n"))
168
177
  }
169
178
 
170
179
  async function initLoop(directory, client, sessionID, args) {
@@ -1,7 +1,9 @@
1
1
  import { parseLoopArgs as defaultParseLoopArgs } from "../core/args.js"
2
- import { jobLabel, isGoalJob } from "../core/jobs.js"
2
+ import { actionKind, jobLabel, isGoalJob } from "../core/jobs.js"
3
+ import { normalizeLoopScheduleArgs as defaultNormalizeLoopScheduleArgs } from "../core/schedule-syntax.js"
3
4
  import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
4
5
  import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
6
+ import { dedicatedGoalOwnsContinuation, findDedicatedGoalForSession as defaultFindDedicatedGoalForSession } from "../runtime/companion-goal.js"
5
7
  import { normalizedModelRef as defaultNormalizedModelRef, getSessionExecutionContext as defaultGetSessionExecutionContext } from "./session-context.js"
6
8
 
7
9
  const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
@@ -32,17 +34,31 @@ export function createLoopRegistration(options = {}) {
32
34
  const toast = requireFunction(options.toast, "toast")
33
35
  const say = requireFunction(options.say, "say")
34
36
  const parseLoopArgs = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : defaultParseLoopArgs
37
+ const normalizeLoopScheduleArgs = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : defaultNormalizeLoopScheduleArgs
35
38
  const readState = typeof options.readState === "function" ? options.readState : defaultReadState
36
39
  const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
37
40
  const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
38
41
  const normalizedModelRef = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : defaultNormalizedModelRef
39
42
  const getSessionExecutionContext = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : defaultGetSessionExecutionContext
43
+ const findDedicatedGoalForSession = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : defaultFindDedicatedGoalForSession
40
44
  const configuredGuard = Number(options.defaultActiveGuardMs)
41
45
  const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS
42
46
 
43
47
  async function addLoop(directory, client, sessionID, args, defaults = {}) {
44
- const parsed = parseLoopArgs(args, defaults)
48
+ const normalized = normalizeLoopScheduleArgs(args, defaults)
49
+ if (!normalized.ok) { await toast(client, normalized.error, "warning"); return }
50
+
51
+ const parsed = parseLoopArgs(normalized.args, normalized.defaults)
45
52
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
53
+ parsed.job.scheduleMode = normalized.scheduleMode
54
+ parsed.job.scheduleSyntax = normalized.scheduleSyntax
55
+ parsed.job.allowGoalOverlap = normalized.allowGoalOverlap === true
56
+ if (normalized.scheduleSyntax === "after") {
57
+ parsed.job.immediate = false
58
+ parsed.job.maxRuns = 1
59
+ parsed.job.lastRunAt = Date.parse(parsed.job.createdAt || "") || Date.now()
60
+ }
61
+
46
62
  const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" }
47
63
  parsed.job.agent = defaults.agent || executionContext.agent || "build"
48
64
  parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
@@ -63,6 +79,21 @@ export function createLoopRegistration(options = {}) {
63
79
  ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
64
80
  : Math.max(defaultActiveGuardMs, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
65
81
  }
82
+
83
+ const promptProducing = actionKind(parsed.job.action, parsed.job) === "prompt"
84
+ if (promptProducing && !parsed.job.allowGoalOverlap) {
85
+ const dedicatedGoal = await findDedicatedGoalForSession(directory, sessionID)
86
+ if (dedicatedGoalOwnsContinuation(dedicatedGoal)) {
87
+ await appendLoopLog(directory, "goal-overlap-blocked", {
88
+ sessionID,
89
+ job: parsed.job.name || parsed.job.id,
90
+ goal: dedicatedGoal.id,
91
+ })
92
+ await toast(client, "Prompt loop not added: dedicated /goal already owns continuation in this session. Pause/finish the Goal, use another session, or pass --allow-goal-overlap intentionally.", "warning")
93
+ return
94
+ }
95
+ }
96
+
66
97
  if (parsed.job.dryRun) {
67
98
  await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info")
68
99
  await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```")
@@ -93,7 +124,13 @@ export function createLoopRegistration(options = {}) {
93
124
  await scheduleDueWork(directory, client, sessionID)
94
125
  if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
95
126
  await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
96
- await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
127
+ await appendLoopLog(directory, replaced ? "replace" : "add", {
128
+ sessionID,
129
+ job: parsed.job.name || parsed.job.id,
130
+ label: jobLabel(parsed.job),
131
+ scheduleMode: parsed.job.scheduleMode,
132
+ scheduleSyntax: parsed.job.scheduleSyntax,
133
+ })
97
134
  }
98
135
 
99
136
  return { addLoop }
@@ -0,0 +1,37 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ function goalRoot(directory) {
5
+ return path.join(directory, ".opencode", "goals")
6
+ }
7
+
8
+ export async function findDedicatedGoalForSession(directory, sessionID) {
9
+ if (!directory || !sessionID) return undefined
10
+ let names
11
+ try {
12
+ names = await fs.readdir(goalRoot(directory))
13
+ } catch (error) {
14
+ if (error?.code === "ENOENT") return undefined
15
+ return undefined
16
+ }
17
+
18
+ for (const name of names) {
19
+ if (!name.endsWith(".json")) continue
20
+ try {
21
+ const value = JSON.parse(await fs.readFile(path.join(goalRoot(directory), name), "utf8"))
22
+ if (value?.sessionID === sessionID) return value
23
+ } catch {}
24
+ }
25
+ return undefined
26
+ }
27
+
28
+ export function dedicatedGoalOwnsContinuation(goal) {
29
+ return goal?.status === "active"
30
+ }
31
+
32
+ export function dedicatedGoalSummary(goal) {
33
+ if (!goal) return "not detected"
34
+ const id = String(goal.id || "unknown").slice(0, 12)
35
+ const status = String(goal.status || "unknown")
36
+ return `${status} (${id})`
37
+ }
@@ -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
+ }
@@ -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) {
@@ -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
  }