@bybrawe/opencode-loop 0.5.9 → 0.5.11
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 +8 -0
- package/README.md +8 -3
- package/package.json +2 -2
- package/src/index.js +230 -105
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.11
|
|
4
|
+
|
|
5
|
+
- Added a watchdog loop in addition to one-shot due timers, so recurring jobs keep checking due work even if OpenCode misses an idle event or a due timeout gets effectively stuck behind a stale busy state.
|
|
6
|
+
- Added stronger stale-active-run recovery. If a plugin-injected run is still marked active after the recovery window, the scheduler treats it as finished, finalizes it, and continues without waiting for another manual command such as `/loop-status`.
|
|
7
|
+
- Refreshed session status with workspace-aware calls before trusting cached busy/retry state.
|
|
8
|
+
- Kept `/compact` routing from v0.5.9: `session_compact` first, then `session.compact`, then summarize fallback.
|
|
9
|
+
- This targets the observed case where `/loop 1m ...` only continued after manually running `/loop-status`.
|
|
10
|
+
|
|
3
11
|
## 0.5.9
|
|
4
12
|
|
|
5
13
|
- Fixed stale-busy recovery for TUI sessions. Cached `busy` / `retry` status is now short-lived, so due jobs no longer get stuck at `due in every idle` after `/loop-status` or other short custom-command turns.
|
package/README.md
CHANGED
|
@@ -9,9 +9,14 @@ It is useful for long coding sessions, `progress.md` workflows, TODO automation,
|
|
|
9
9
|
Repository: **ByBrawe/opencode-loop**
|
|
10
10
|
NPM package name: **@bybrawe/opencode-loop**
|
|
11
11
|
|
|
12
|
+
### Scheduler heartbeat note
|
|
13
|
+
|
|
14
|
+
v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode TUI because local command hooks are event-driven: a normal `/loop-status` command can wake the plugin, but a timer-only loop should not depend on a user typing another command. The heartbeat periodically checks active loop jobs, clears stale completed runs, and starts due work only when the session appears idle.
|
|
15
|
+
|
|
16
|
+
|
|
12
17
|
## Current status
|
|
13
18
|
|
|
14
|
-
**v0.5.
|
|
19
|
+
**v0.5.11 is a reliability hotfix for recurring timers that only continued after manual activity.** It keeps the real due timer from v0.5.8 and the stale-busy recovery from v0.5.9, then adds a watchdog checker and stronger stale-active-run recovery. This targets the case where `/loop 1m ...` reaches its due time but does not fire until you run `/loop-status` or type another command. Scheduled `/compact` still routes through OpenCode's current TUI alias first, with fallback paths for older builds.
|
|
15
20
|
|
|
16
21
|
The known update-related symptoms from older builds are fixed:
|
|
17
22
|
|
|
@@ -27,7 +32,7 @@ The known update-related symptoms from older builds are fixed:
|
|
|
27
32
|
|
|
28
33
|
The TUI loop is still intentionally session-bound: it runs while OpenCode is open and the current session emits status/idle events. For long-running background work after closing the terminal or OpenCode, use `opencode-loopd`.
|
|
29
34
|
|
|
30
|
-
## v0.5.
|
|
35
|
+
## v0.5.11 quick behavior guide
|
|
31
36
|
|
|
32
37
|
OpenCode Loop has two triggers now:
|
|
33
38
|
|
|
@@ -74,7 +79,7 @@ Run a shell command every 10 minutes when idle.
|
|
|
74
79
|
|
|
75
80
|
Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
|
|
76
81
|
|
|
77
|
-
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.
|
|
82
|
+
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.11 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin. If that short turn briefly makes OpenCode look busy, the loop now recovers with fresh status checks, stale-active-run recovery, and a watchdog that does not require another manual command.
|
|
78
83
|
|
|
79
84
|
## Why this exists
|
|
80
85
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode:
|
|
3
|
+
"version": "0.5.11",
|
|
4
|
+
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/index.js
CHANGED
|
@@ -4,12 +4,14 @@ import { spawn } from "node:child_process"
|
|
|
4
4
|
|
|
5
5
|
const SERVICE = "opencode-loop"
|
|
6
6
|
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
|
-
const DEFAULT_ACTIVE_GUARD_MS =
|
|
7
|
+
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
8
|
+
const STALE_ACTIVE_RECOVERY_MS = 45_000
|
|
8
9
|
const IDLE_DEBOUNCE_MS = 1_200
|
|
9
10
|
const BUSY_RETRY_MS = 5_000
|
|
10
11
|
const SESSION_STATUS_CACHE_MS = 1_500
|
|
11
12
|
const MIN_DUE_TIMER_MS = 250
|
|
12
13
|
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
14
|
+
const HEARTBEAT_MS = 2_500
|
|
13
15
|
const MAX_SCAN_FILES = 200
|
|
14
16
|
const MAX_SCAN_BYTES = 2_000_000
|
|
15
17
|
const GOAL_REPORT_DIR = "goals"
|
|
@@ -19,6 +21,10 @@ const activeRuns = new Map()
|
|
|
19
21
|
const handledCommands = new Map()
|
|
20
22
|
const idleTimers = new Map()
|
|
21
23
|
const dueTimers = new Map()
|
|
24
|
+
const watchdogTimers = new Map()
|
|
25
|
+
const runLocks = new Map()
|
|
26
|
+
const knownSessions = new Map()
|
|
27
|
+
let heartbeatTimer
|
|
22
28
|
const sessionStatuses = new Map()
|
|
23
29
|
const sessionStatusSeenAt = new Map()
|
|
24
30
|
|
|
@@ -442,7 +448,7 @@ async function say(client, sessionID, text) {
|
|
|
442
448
|
} catch {}
|
|
443
449
|
}
|
|
444
450
|
|
|
445
|
-
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${args
|
|
451
|
+
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
|
|
446
452
|
function markHandled(sessionID, name, args) {
|
|
447
453
|
handledCommands.set(commandKey(sessionID, name, args), now())
|
|
448
454
|
for (const [key, time] of handledCommands.entries()) if (now() - time > 30_000) handledCommands.delete(key)
|
|
@@ -454,6 +460,41 @@ function wasHandled(sessionID, name, args) {
|
|
|
454
460
|
|
|
455
461
|
function commandName(name) { return String(name || "") }
|
|
456
462
|
function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
|
|
463
|
+
|
|
464
|
+
function normalizeArgsForKey(args) {
|
|
465
|
+
if (args === undefined || args === null) return ""
|
|
466
|
+
if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
|
|
467
|
+
if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
|
|
468
|
+
try { return JSON.stringify(args) } catch { return String(args) }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function rememberSession(directory, client, sessionID) {
|
|
472
|
+
if (!sessionID) return
|
|
473
|
+
knownSessions.set(sessionID, { directory, client, seenAt: now() })
|
|
474
|
+
startHeartbeat()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function startHeartbeat() {
|
|
478
|
+
if (heartbeatTimer) return
|
|
479
|
+
heartbeatTimer = setInterval(() => {
|
|
480
|
+
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
481
|
+
if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
|
|
482
|
+
knownSessions.delete(sessionID)
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
Promise.resolve()
|
|
486
|
+
.then(async () => {
|
|
487
|
+
await finalizeActiveRun(info.directory, info.client, sessionID, { forceStale: true })
|
|
488
|
+
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
|
|
489
|
+
})
|
|
490
|
+
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
491
|
+
}
|
|
492
|
+
if (!knownSessions.size && heartbeatTimer) {
|
|
493
|
+
clearInterval(heartbeatTimer)
|
|
494
|
+
heartbeatTimer = undefined
|
|
495
|
+
}
|
|
496
|
+
}, HEARTBEAT_MS)
|
|
497
|
+
}
|
|
457
498
|
function presetDefaults(name, args) {
|
|
458
499
|
const [maybeDuration, rest] = splitFirst(args)
|
|
459
500
|
const parsed = parseDuration(maybeDuration)
|
|
@@ -757,7 +798,34 @@ function updateSessionStatusFromEvent(event) {
|
|
|
757
798
|
return undefined
|
|
758
799
|
}
|
|
759
800
|
|
|
760
|
-
|
|
801
|
+
function staleActiveRun(sessionID) {
|
|
802
|
+
const active = activeRuns.get(sessionID)
|
|
803
|
+
if (!active) return false
|
|
804
|
+
const age = now() - (active.startedAt || 0)
|
|
805
|
+
const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
|
|
806
|
+
const threshold = Number.isFinite(configured) && configured > 0 ? configured : STALE_ACTIVE_RECOVERY_MS
|
|
807
|
+
return age >= threshold
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
811
|
+
const argsList = []
|
|
812
|
+
if (directory) argsList.push({ workspace: directory }, { directory })
|
|
813
|
+
argsList.push({})
|
|
814
|
+
for (const args of argsList) {
|
|
815
|
+
try {
|
|
816
|
+
const result = await client.session.status(args)
|
|
817
|
+
const error = sdkError(result)
|
|
818
|
+
if (error) continue
|
|
819
|
+
const data = sdkData(result)
|
|
820
|
+
const status = data?.[sessionID]
|
|
821
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
822
|
+
if (typeof type === "string") return { type, source: "sdk" }
|
|
823
|
+
} catch {}
|
|
824
|
+
}
|
|
825
|
+
return undefined
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
761
829
|
const cached = sessionStatuses.get(sessionID)
|
|
762
830
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
763
831
|
|
|
@@ -769,25 +837,30 @@ async function sessionStatusType(client, sessionID) {
|
|
|
769
837
|
if (cached === "idle") return cached
|
|
770
838
|
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
|
|
771
839
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
840
|
+
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
841
|
+
if (live?.type) {
|
|
842
|
+
// Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
|
|
843
|
+
// plugin-injected turn until the next user command touches the session.
|
|
844
|
+
// When the only reason we still think the session is busy is our own stale
|
|
845
|
+
// active-run guard, recover instead of waiting for another manual command.
|
|
846
|
+
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) {
|
|
847
|
+
sessionStatuses.set(sessionID, "idle")
|
|
778
848
|
sessionStatusSeenAt.set(sessionID, now())
|
|
779
|
-
return
|
|
849
|
+
return "idle"
|
|
780
850
|
}
|
|
781
|
-
|
|
851
|
+
sessionStatuses.set(sessionID, live.type)
|
|
852
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
853
|
+
return live.type
|
|
854
|
+
}
|
|
782
855
|
|
|
783
|
-
const fallback = activeRuns.has(sessionID) ? "busy" : "idle"
|
|
856
|
+
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
|
|
784
857
|
sessionStatuses.set(sessionID, fallback)
|
|
785
858
|
sessionStatusSeenAt.set(sessionID, now())
|
|
786
859
|
return fallback
|
|
787
860
|
}
|
|
788
861
|
|
|
789
|
-
async function sessionIsIdle(client, sessionID) {
|
|
790
|
-
return await sessionStatusType(client, sessionID) === "idle"
|
|
862
|
+
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
863
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle"
|
|
791
864
|
}
|
|
792
865
|
|
|
793
866
|
function scheduleIdleWork(directory, client, sessionID) {
|
|
@@ -797,12 +870,12 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
797
870
|
idleTimers.delete(sessionID)
|
|
798
871
|
Promise.resolve()
|
|
799
872
|
.then(async () => {
|
|
800
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
873
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
801
874
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
802
875
|
return
|
|
803
876
|
}
|
|
804
877
|
await finalizeActiveRun(directory, client, sessionID)
|
|
805
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
878
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
806
879
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
807
880
|
return
|
|
808
881
|
}
|
|
@@ -836,6 +909,37 @@ function nextDueDelay(state) {
|
|
|
836
909
|
return Math.max(0, soonest - current)
|
|
837
910
|
}
|
|
838
911
|
|
|
912
|
+
async function startWatchdog(directory, client, sessionID) {
|
|
913
|
+
if (watchdogTimers.has(sessionID)) return
|
|
914
|
+
const timer = setInterval(() => {
|
|
915
|
+
Promise.resolve()
|
|
916
|
+
.then(async () => {
|
|
917
|
+
const state = await readState(directory, sessionID)
|
|
918
|
+
const delay = nextDueDelay(state)
|
|
919
|
+
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
|
|
920
|
+
if (!hasJobs || !Number.isFinite(delay)) {
|
|
921
|
+
const existing = watchdogTimers.get(sessionID)
|
|
922
|
+
if (existing) clearInterval(existing)
|
|
923
|
+
watchdogTimers.delete(sessionID)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
if (delay <= 0) await maybeRunDueJobs(directory, client, sessionID)
|
|
927
|
+
else await scheduleDueWork(directory, client, sessionID)
|
|
928
|
+
})
|
|
929
|
+
.catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
930
|
+
}, Math.max(1_000, BUSY_RETRY_MS))
|
|
931
|
+
// Keep this interval referenced. In current OpenCode TUI builds, plugin hooks
|
|
932
|
+
// are event-driven; a referenced watchdog helps scheduled loops wake up even
|
|
933
|
+
// when no manual /loop-status command is typed.
|
|
934
|
+
watchdogTimers.set(sessionID, timer)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function stopWatchdog(sessionID) {
|
|
938
|
+
const timer = watchdogTimers.get(sessionID)
|
|
939
|
+
if (timer) clearInterval(timer)
|
|
940
|
+
watchdogTimers.delete(sessionID)
|
|
941
|
+
}
|
|
942
|
+
|
|
839
943
|
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
840
944
|
const previous = dueTimers.get(sessionID)
|
|
841
945
|
if (previous) clearTimeout(previous)
|
|
@@ -852,12 +956,12 @@ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
|
852
956
|
dueTimers.delete(sessionID)
|
|
853
957
|
Promise.resolve()
|
|
854
958
|
.then(async () => {
|
|
855
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
959
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
856
960
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
857
961
|
return
|
|
858
962
|
}
|
|
859
963
|
await finalizeActiveRun(directory, client, sessionID)
|
|
860
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
964
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
861
965
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
862
966
|
return
|
|
863
967
|
}
|
|
@@ -869,6 +973,7 @@ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
|
869
973
|
})
|
|
870
974
|
}, wait)
|
|
871
975
|
dueTimers.set(sessionID, timer)
|
|
976
|
+
await startWatchdog(directory, client, sessionID)
|
|
872
977
|
}
|
|
873
978
|
|
|
874
979
|
function dueJobs(state, force = false) {
|
|
@@ -1130,112 +1235,128 @@ ${prompt}` }] } })
|
|
|
1130
1235
|
}
|
|
1131
1236
|
|
|
1132
1237
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1238
|
+
rememberSession(directory, client, sessionID)
|
|
1133
1239
|
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1134
1240
|
|
|
1135
|
-
if (
|
|
1136
|
-
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1137
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1138
|
-
return
|
|
1139
|
-
}
|
|
1140
|
-
const active = activeRuns.get(sessionID)
|
|
1141
|
-
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
|
|
1241
|
+
if (runLocks.has(sessionID)) {
|
|
1142
1242
|
await reschedule(BUSY_RETRY_MS)
|
|
1143
1243
|
return
|
|
1144
1244
|
}
|
|
1245
|
+
runLocks.set(sessionID, now())
|
|
1246
|
+
let job
|
|
1247
|
+
try {
|
|
1248
|
+
await finalizeActiveRun(directory, client, sessionID, { forceStale: true })
|
|
1249
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1250
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1251
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1145
1254
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
return
|
|
1155
|
-
}
|
|
1156
|
-
let job = due[0]
|
|
1255
|
+
const active = activeRuns.get(sessionID)
|
|
1256
|
+
const activeAge = active ? now() - (active.startedAt || 0) : 0
|
|
1257
|
+
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
|
|
1258
|
+
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
1259
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1260
|
+
return
|
|
1261
|
+
}
|
|
1262
|
+
if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
|
|
1157
1263
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
await writeState(directory, sessionID, state)
|
|
1170
|
-
await notifyJob(directory, job, "stop_file")
|
|
1171
|
-
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1172
|
-
await reschedule()
|
|
1173
|
-
return
|
|
1174
|
-
}
|
|
1175
|
-
if (await untilReached(directory, job)) {
|
|
1176
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1177
|
-
await writeState(directory, sessionID, state)
|
|
1178
|
-
await notifyJob(directory, job, "until_reached")
|
|
1179
|
-
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1180
|
-
await reschedule()
|
|
1181
|
-
return
|
|
1182
|
-
}
|
|
1264
|
+
const state = await readState(directory, sessionID)
|
|
1265
|
+
for (const candidate of state.jobs || []) {
|
|
1266
|
+
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.lastRunAt = 0
|
|
1267
|
+
}
|
|
1268
|
+
const due = dueJobs(state, options.force)
|
|
1269
|
+
if (!due.length) {
|
|
1270
|
+
await writeState(directory, sessionID, state)
|
|
1271
|
+
await reschedule()
|
|
1272
|
+
return
|
|
1273
|
+
}
|
|
1274
|
+
job = due[0]
|
|
1183
1275
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
job.paused = true
|
|
1276
|
+
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
1277
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1187
1278
|
await writeState(directory, sessionID, state)
|
|
1188
|
-
await notifyJob(directory, job, "
|
|
1189
|
-
await toast(client,
|
|
1279
|
+
await notifyJob(directory, job, "max_runtime_reached")
|
|
1280
|
+
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
1281
|
+
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1190
1282
|
await reschedule()
|
|
1191
1283
|
return
|
|
1192
1284
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
if (preflight.code !== 0) {
|
|
1196
|
-
job.paused = true
|
|
1197
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1198
|
-
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1199
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1285
|
+
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
1286
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1200
1287
|
await writeState(directory, sessionID, state)
|
|
1201
|
-
await notifyJob(directory, job, "
|
|
1202
|
-
await toast(client, "
|
|
1288
|
+
await notifyJob(directory, job, "stop_file")
|
|
1289
|
+
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1203
1290
|
await reschedule()
|
|
1204
1291
|
return
|
|
1205
1292
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
job.runCount = (job.runCount || 0) + 1
|
|
1212
|
-
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1213
|
-
job.enabled = false
|
|
1214
|
-
await notifyJob(directory, job, "max_runs_reached")
|
|
1215
|
-
}
|
|
1216
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1217
|
-
await writeState(directory, sessionID, state)
|
|
1218
|
-
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1219
|
-
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1220
|
-
|
|
1221
|
-
try {
|
|
1222
|
-
const result = await fireAction(directory, client, sessionID, job)
|
|
1223
|
-
if (!result.startsAssistantTurn) {
|
|
1224
|
-
const fresh = await readState(directory, sessionID)
|
|
1225
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1226
|
-
await writeState(directory, sessionID, fresh)
|
|
1293
|
+
if (await untilReached(directory, job)) {
|
|
1294
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1295
|
+
await writeState(directory, sessionID, state)
|
|
1296
|
+
await notifyJob(directory, job, "until_reached")
|
|
1297
|
+
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1227
1298
|
await reschedule()
|
|
1228
1299
|
return
|
|
1229
1300
|
}
|
|
1230
|
-
|
|
1231
|
-
if (job.
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1301
|
+
|
|
1302
|
+
if (job.preflightCommand) {
|
|
1303
|
+
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
1304
|
+
job.paused = true
|
|
1305
|
+
await writeState(directory, sessionID, state)
|
|
1306
|
+
await notifyJob(directory, job, "preflight_blocked")
|
|
1307
|
+
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1308
|
+
await reschedule()
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
1312
|
+
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
|
|
1313
|
+
if (preflight.code !== 0) {
|
|
1314
|
+
job.paused = true
|
|
1315
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1316
|
+
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1317
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1318
|
+
await writeState(directory, sessionID, state)
|
|
1319
|
+
await notifyJob(directory, job, "preflight_failed")
|
|
1320
|
+
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1321
|
+
await reschedule()
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
job = await ensureBranch(directory, job, client, sessionID)
|
|
1327
|
+
job = await maybeCompact(client, sessionID, job)
|
|
1328
|
+
job.lastRunAt = now()
|
|
1329
|
+
job.runCount = (job.runCount || 0) + 1
|
|
1330
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1331
|
+
job.enabled = false
|
|
1332
|
+
await notifyJob(directory, job, "max_runs_reached")
|
|
1333
|
+
}
|
|
1334
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1335
|
+
await writeState(directory, sessionID, state)
|
|
1336
|
+
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1337
|
+
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1338
|
+
|
|
1339
|
+
try {
|
|
1340
|
+
const result = await fireAction(directory, client, sessionID, job)
|
|
1341
|
+
if (!result.startsAssistantTurn) {
|
|
1342
|
+
const fresh = await readState(directory, sessionID)
|
|
1343
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1344
|
+
await writeState(directory, sessionID, fresh)
|
|
1345
|
+
await reschedule()
|
|
1346
|
+
return
|
|
1347
|
+
}
|
|
1348
|
+
let timer
|
|
1349
|
+
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
1350
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1351
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
clearActiveRun(sessionID)
|
|
1354
|
+
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
1355
|
+
await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
|
|
1356
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1357
|
+
}
|
|
1358
|
+
} finally {
|
|
1359
|
+
runLocks.delete(sessionID)
|
|
1239
1360
|
}
|
|
1240
1361
|
}
|
|
1241
1362
|
|
|
@@ -1256,6 +1377,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
1256
1377
|
const parsed = parseLoopArgs(args, defaults)
|
|
1257
1378
|
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
1258
1379
|
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
1380
|
+
if (!parsed.job.activeRecoveryMs) parsed.job.activeRecoveryMs = Math.max(20_000, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
1259
1381
|
if (parsed.job.dryRun) { await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); return }
|
|
1260
1382
|
const state = await readState(directory, sessionID)
|
|
1261
1383
|
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
@@ -1291,6 +1413,7 @@ async function stopLoop(directory, client, sessionID, args) {
|
|
|
1291
1413
|
await removeState(directory, sessionID)
|
|
1292
1414
|
clearActiveRun(sessionID)
|
|
1293
1415
|
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
1416
|
+
stopWatchdog(sessionID)
|
|
1294
1417
|
await toast(client, "All loops stopped for this session.", "success")
|
|
1295
1418
|
return
|
|
1296
1419
|
}
|
|
@@ -1467,6 +1590,7 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
1467
1590
|
const sessionID = input?.sessionID
|
|
1468
1591
|
const args = input?.arguments ?? fallbackArgs ?? ""
|
|
1469
1592
|
if (!sessionID || !name) return false
|
|
1593
|
+
rememberSession(directory, client, sessionID)
|
|
1470
1594
|
if (wasHandled(sessionID, name, args)) return true
|
|
1471
1595
|
markHandled(sessionID, name, args)
|
|
1472
1596
|
|
|
@@ -1551,6 +1675,7 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
1551
1675
|
await handleCommand(directory, client, props, props.name, props.arguments)
|
|
1552
1676
|
}
|
|
1553
1677
|
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
1678
|
+
if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
|
|
1554
1679
|
if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
|
|
1555
1680
|
},
|
|
1556
1681
|
}
|