@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.
- package/CHANGELOG.md +10 -0
- package/README.md +197 -283
- package/commands/loop.md +1 -1
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +1 -1
- package/scripts/host-loop-canary.mjs +8 -3
- package/scripts/loop-command-handlers-test.mjs +33 -4
- package/scripts/loop-registration-test.mjs +77 -3
- package/scripts/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 +433 -100
- 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/companion-goal.js +37 -0
- package/src/source/runtime/loop-diagnostics.js +45 -0
- package/src/source/runtime/run-admission.js +2 -13
- 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,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
|
+
}
|
package/src/source/core/jobs.js
CHANGED
|
@@ -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
|
|
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
|
|
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} |
|
|
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
|
|
121
|
-
"/loop
|
|
122
|
-
"/loop
|
|
123
|
-
"/loop-
|
|
124
|
-
"/loop
|
|
125
|
-
"/loop-
|
|
126
|
-
"/loop-
|
|
127
|
-
"/loop-
|
|
128
|
-
"/loop
|
|
129
|
-
"/loop
|
|
130
|
-
"/loop 0s --
|
|
131
|
-
"/loop
|
|
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
|
|
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
|
-
`-
|
|
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
|
|
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
|
-
]
|
|
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
|
|
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", {
|
|
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
|
-
|
|
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
|
}
|