@bybrawe/opencode-loop 0.5.7 → 0.5.8
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 +52 -1
- package/commands/loop-ask.md +1 -1
- package/commands/loop-clear.md +2 -2
- package/commands/loop-cmd.md +1 -1
- package/commands/loop-command.md +1 -1
- package/commands/loop-doctor.md +2 -2
- package/commands/loop-export.md +2 -2
- package/commands/loop-goal-blocked.md +2 -2
- package/commands/loop-goal-clear.md +2 -2
- package/commands/loop-goal-done.md +2 -2
- package/commands/loop-goal-pause.md +2 -2
- package/commands/loop-goal-resume.md +2 -2
- package/commands/loop-goal-status.md +2 -2
- package/commands/loop-goal.md +1 -1
- package/commands/loop-help.md +1 -1
- package/commands/loop-init.md +2 -2
- package/commands/loop-logs.md +1 -1
- package/commands/loop-now.md +2 -2
- package/commands/loop-pause.md +2 -2
- package/commands/loop-resume.md +2 -2
- package/commands/loop-shell.md +1 -1
- package/commands/loop-status.md +1 -1
- package/commands/loop-stop.md +1 -1
- package/commands/loop.md +1 -1
- package/package.json +2 -2
- package/src/index.js +112 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.8
|
|
4
|
+
|
|
5
|
+
- Added a real due timer for TUI loop jobs, so delayed jobs can wake without waiting for another OpenCode event.
|
|
6
|
+
- Fixed `/loop 1m --no-now ...` style jobs so the first delayed run can fire on time when OpenCode is idle.
|
|
7
|
+
- Fixed scheduled action types such as `/loop-ask`, `/loop-command`, and `/loop-shell` so 0s and delayed jobs are scheduled even when their default mode is `--no-now`.
|
|
8
|
+
- Added busy retry behavior: if a due timer expires while OpenCode is busy, the job waits and retries instead of interrupting the active turn.
|
|
9
|
+
- Reschedules due timers after add, stop, pause, resume, run completion, goal pause/resume/clear, and post-run verification.
|
|
10
|
+
- Shortened command markdown templates so unavoidable custom command turns reply `OK` instead of producing misleading status text.
|
|
11
|
+
- Kept Experimental Goal Mode marked experimental.
|
|
12
|
+
|
|
3
13
|
## 0.5.7
|
|
4
14
|
|
|
5
15
|
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ NPM package name: **@bybrawe/opencode-loop**
|
|
|
11
11
|
|
|
12
12
|
## Current status
|
|
13
13
|
|
|
14
|
-
**v0.5.
|
|
14
|
+
**v0.5.8 is a reliability hotfix for the new loop types and Experimental Goal Mode.** It adds a real due timer in addition to OpenCode idle/status events, so delayed jobs such as `/loop 1m --no-now ...`, `/loop-command 1m --no-now /compact`, `/loop-ask 1h ...`, and `/loop-shell 10m ...` do not depend on another user action to wake the scheduler. The TUI loop still waits for the session to become idle before starting work, debounces idle events, and avoids starting a new run while OpenCode is busy or queued.
|
|
15
15
|
|
|
16
16
|
The known update-related symptoms from older builds are fixed:
|
|
17
17
|
|
|
@@ -22,9 +22,60 @@ The known update-related symptoms from older builds are fixed:
|
|
|
22
22
|
- `/compact` accidentally being treated as a normal agent prompt
|
|
23
23
|
- intermittent `Tool execution aborted` behavior caused by triggering a new turn too early
|
|
24
24
|
- experimental `/loop-goal` support for goal-driven work that continues until complete, blocked, paused, or cleared
|
|
25
|
+
- `--no-now` interval jobs not waking until another OpenCode event happens
|
|
26
|
+
- `/loop-ask`, `/loop-command`, and `/loop-shell` being configured but not reliably firing on their own
|
|
25
27
|
|
|
26
28
|
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`.
|
|
27
29
|
|
|
30
|
+
## v0.5.8 quick behavior guide
|
|
31
|
+
|
|
32
|
+
OpenCode Loop has two triggers now:
|
|
33
|
+
|
|
34
|
+
1. **Idle/status events** from OpenCode. These are used when OpenCode finishes a turn.
|
|
35
|
+
2. **A real due timer** inside the plugin. This wakes delayed jobs even when no new OpenCode event happens.
|
|
36
|
+
|
|
37
|
+
All TUI jobs are still idle-safe. If the timer expires while OpenCode is busy, the job does not interrupt the active turn. It waits and retries until the session becomes idle.
|
|
38
|
+
|
|
39
|
+
Simple examples:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
/loop 0s continue the project
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Run every time OpenCode becomes idle.
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
/loop 1m --no-now continue the project
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Wait 1 minute first, then run when OpenCode is idle.
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
/loop-command 200m /compact
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run OpenCode compact about every 200 minutes, but only when OpenCode is idle.
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
/loop-ask 1h did you run tests, tsc --noEmit, and build? If not, run them and fix errors.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Ask a recurring quality-control question every hour.
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
/loop-shell 10m npm test
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run a shell command every 10 minutes when idle.
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
/loop-goal --max-turns 5 --check "npm run build" --complete-when-checks-pass make the project build cleanly
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
|
|
76
|
+
|
|
77
|
+
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.8 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin.
|
|
78
|
+
|
|
28
79
|
## Why this exists
|
|
29
80
|
|
|
30
81
|
Claude Code users often rely on a loop-like workflow where the agent finishes one step, then immediately continues with the next step.
|
package/commands/loop-ask.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Schedule a recurring prompt/question. Usage: /loop-ask 1h did you run tests?
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Loop ask scheduler
|
|
5
|
+
OpenCode Loop ask scheduler handled locally. Reply exactly: OK.
|
package/commands/loop-clear.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Clear all OpenCode Loop jobs for this session.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop clear command handled locally. Reply exactly: OK.
|
package/commands/loop-cmd.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Alias for /loop-command. Usage: /loop-cmd 200m /compact
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Loop command scheduler
|
|
5
|
+
OpenCode Loop command scheduler handled locally. Reply exactly: OK.
|
package/commands/loop-command.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Schedule an OpenCode slash command loop. Usage: /loop-command 200m /compact
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Loop command scheduler
|
|
5
|
+
OpenCode Loop command scheduler handled locally. Reply exactly: OK.
|
package/commands/loop-doctor.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Run OpenCode Loop diagnostics.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop doctor command handled locally. Reply exactly: OK.
|
package/commands/loop-export.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Export OpenCode Loop state.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop export command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Mark experimental goal blocked.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal blocked command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Clear experimental goal jobs.
|
|
2
|
+
description: Clear experimental goal jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal clear command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Mark experimental goal done.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal done command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Pause
|
|
2
|
+
description: Pause experimental goal jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal pause command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Resume
|
|
2
|
+
description: Resume experimental goal jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal resume command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Show experimental goal status.
|
|
2
|
+
description: Show experimental goal status.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal status command handled locally. Reply exactly: OK.
|
package/commands/loop-goal.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Experimental persistent goal mode. Usage: /loop-goal finish the feature and keep tests green
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal command handled locally. Reply exactly: OK.
|
package/commands/loop-help.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Show OpenCode Loop help.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop help command handled locally. Reply exactly: OK.
|
package/commands/loop-init.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Create a starter progress.md file.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop init command handled locally. Reply exactly: OK.
|
package/commands/loop-logs.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Show OpenCode Loop logs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop logs command handled locally. Reply exactly: OK.
|
package/commands/loop-now.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Run due OpenCode Loop jobs now when idle.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop run-now command handled locally. Reply exactly: OK.
|
package/commands/loop-pause.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Pause OpenCode Loop jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop pause command handled locally. Reply exactly: OK.
|
package/commands/loop-resume.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Resume OpenCode Loop jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop resume command handled locally. Reply exactly: OK.
|
package/commands/loop-shell.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Schedule a recurring shell command. Usage: /loop-shell 10m npm test
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Loop shell scheduler
|
|
5
|
+
OpenCode Loop shell scheduler handled locally. Reply exactly: OK.
|
package/commands/loop-status.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Show OpenCode Loop status.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop status command handled locally. Reply exactly: OK.
|
package/commands/loop-stop.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Stop OpenCode Loop jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop stop command handled locally. Reply exactly: OK.
|
package/commands/loop.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Start an OpenCode auto-continue loop. Usage: /loop 5m <task>
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop local command handled. Reply exactly: OK.
|
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.8",
|
|
4
|
+
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: reliable timers, 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
|
@@ -6,6 +6,9 @@ const SERVICE = "opencode-loop"
|
|
|
6
6
|
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
7
|
const DEFAULT_ACTIVE_GUARD_MS = 60_000
|
|
8
8
|
const IDLE_DEBOUNCE_MS = 1_200
|
|
9
|
+
const BUSY_RETRY_MS = 5_000
|
|
10
|
+
const MIN_DUE_TIMER_MS = 250
|
|
11
|
+
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
9
12
|
const MAX_SCAN_FILES = 200
|
|
10
13
|
const MAX_SCAN_BYTES = 2_000_000
|
|
11
14
|
const GOAL_REPORT_DIR = "goals"
|
|
@@ -14,6 +17,7 @@ const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
|
14
17
|
const activeRuns = new Map()
|
|
15
18
|
const handledCommands = new Map()
|
|
16
19
|
const idleTimers = new Map()
|
|
20
|
+
const dueTimers = new Map()
|
|
17
21
|
const sessionStatuses = new Map()
|
|
18
22
|
|
|
19
23
|
const DEFAULT_PROGRESS_MD = `# Progress
|
|
@@ -753,8 +757,9 @@ async function sessionStatusType(client, sessionID) {
|
|
|
753
757
|
return type
|
|
754
758
|
}
|
|
755
759
|
} catch {}
|
|
756
|
-
|
|
757
|
-
|
|
760
|
+
const fallback = activeRuns.has(sessionID) ? "busy" : "idle"
|
|
761
|
+
sessionStatuses.set(sessionID, fallback)
|
|
762
|
+
return fallback
|
|
758
763
|
}
|
|
759
764
|
|
|
760
765
|
async function sessionIsIdle(client, sessionID) {
|
|
@@ -768,9 +773,15 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
768
773
|
idleTimers.delete(sessionID)
|
|
769
774
|
Promise.resolve()
|
|
770
775
|
.then(async () => {
|
|
771
|
-
if (!await sessionIsIdle(client, sessionID))
|
|
776
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
777
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
778
|
+
return
|
|
779
|
+
}
|
|
772
780
|
await finalizeActiveRun(directory, client, sessionID)
|
|
773
|
-
if (!await sessionIsIdle(client, sessionID))
|
|
781
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
782
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
783
|
+
return
|
|
784
|
+
}
|
|
774
785
|
await maybeRunDueJobs(directory, client, sessionID)
|
|
775
786
|
})
|
|
776
787
|
.catch((error) => {
|
|
@@ -781,6 +792,61 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
781
792
|
idleTimers.set(sessionID, timer)
|
|
782
793
|
}
|
|
783
794
|
|
|
795
|
+
function jobDueAt(job, current = now()) {
|
|
796
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
|
|
797
|
+
if (!job.enabled || job.paused) return Infinity
|
|
798
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
|
|
799
|
+
if (job.watchPaths?.length) return Infinity
|
|
800
|
+
const created = Date.parse(job.createdAt || new Date().toISOString())
|
|
801
|
+
if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs) return current
|
|
802
|
+
if (job.intervalMs === 0) return current
|
|
803
|
+
if (!job.lastRunAt) return current
|
|
804
|
+
return job.lastRunAt + (job.intervalMs || 0)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function nextDueDelay(state) {
|
|
808
|
+
const current = now()
|
|
809
|
+
let soonest = Infinity
|
|
810
|
+
for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
811
|
+
if (!Number.isFinite(soonest)) return Infinity
|
|
812
|
+
return Math.max(0, soonest - current)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
816
|
+
const previous = dueTimers.get(sessionID)
|
|
817
|
+
if (previous) clearTimeout(previous)
|
|
818
|
+
|
|
819
|
+
const state = await readState(directory, sessionID)
|
|
820
|
+
const delay = nextDueDelay(state)
|
|
821
|
+
if (!Number.isFinite(delay)) {
|
|
822
|
+
dueTimers.delete(sessionID)
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const wait = Math.min(Math.max(delay, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS)
|
|
827
|
+
const timer = setTimeout(() => {
|
|
828
|
+
dueTimers.delete(sessionID)
|
|
829
|
+
Promise.resolve()
|
|
830
|
+
.then(async () => {
|
|
831
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
832
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
await finalizeActiveRun(directory, client, sessionID)
|
|
836
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
837
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
await maybeRunDueJobs(directory, client, sessionID)
|
|
841
|
+
})
|
|
842
|
+
.catch((error) => {
|
|
843
|
+
toast(client, `Loop due timer failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
844
|
+
appendLoopLog(directory, "due-timer-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
845
|
+
})
|
|
846
|
+
}, wait)
|
|
847
|
+
dueTimers.set(sessionID, timer)
|
|
848
|
+
}
|
|
849
|
+
|
|
784
850
|
function dueJobs(state, force = false) {
|
|
785
851
|
const current = now()
|
|
786
852
|
return (state.jobs || []).filter((job) => {
|
|
@@ -992,6 +1058,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
992
1058
|
await writeState(directory, sessionID, state)
|
|
993
1059
|
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
994
1060
|
await createCheckpoint(directory, sessionID, job, client)
|
|
1061
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
995
1062
|
}
|
|
996
1063
|
|
|
997
1064
|
async function fireAction(directory, client, sessionID, job) {
|
|
@@ -1039,19 +1106,29 @@ ${prompt}` }] } })
|
|
|
1039
1106
|
}
|
|
1040
1107
|
|
|
1041
1108
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1109
|
+
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1110
|
+
|
|
1042
1111
|
if (!await sessionIsIdle(client, sessionID)) {
|
|
1043
|
-
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle
|
|
1112
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1113
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1044
1114
|
return
|
|
1045
1115
|
}
|
|
1046
1116
|
const active = activeRuns.get(sessionID)
|
|
1047
|
-
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS))
|
|
1117
|
+
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
|
|
1118
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1048
1121
|
|
|
1049
1122
|
const state = await readState(directory, sessionID)
|
|
1050
1123
|
for (const job of state.jobs || []) {
|
|
1051
1124
|
if (job.watchPaths?.length && !job.paused && job.enabled && await watchChanged(directory, job)) job.lastRunAt = 0
|
|
1052
1125
|
}
|
|
1053
1126
|
let due = dueJobs(state, options.force)
|
|
1054
|
-
if (!due.length) {
|
|
1127
|
+
if (!due.length) {
|
|
1128
|
+
await writeState(directory, sessionID, state)
|
|
1129
|
+
await reschedule()
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1055
1132
|
let job = due[0]
|
|
1056
1133
|
|
|
1057
1134
|
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
@@ -1060,6 +1137,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1060
1137
|
await notifyJob(directory, job, "max_runtime_reached")
|
|
1061
1138
|
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
1062
1139
|
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1140
|
+
await reschedule()
|
|
1063
1141
|
return
|
|
1064
1142
|
}
|
|
1065
1143
|
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
@@ -1067,6 +1145,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1067
1145
|
await writeState(directory, sessionID, state)
|
|
1068
1146
|
await notifyJob(directory, job, "stop_file")
|
|
1069
1147
|
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1148
|
+
await reschedule()
|
|
1070
1149
|
return
|
|
1071
1150
|
}
|
|
1072
1151
|
if (await untilReached(directory, job)) {
|
|
@@ -1074,6 +1153,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1074
1153
|
await writeState(directory, sessionID, state)
|
|
1075
1154
|
await notifyJob(directory, job, "until_reached")
|
|
1076
1155
|
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1156
|
+
await reschedule()
|
|
1077
1157
|
return
|
|
1078
1158
|
}
|
|
1079
1159
|
|
|
@@ -1083,6 +1163,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1083
1163
|
await writeState(directory, sessionID, state)
|
|
1084
1164
|
await notifyJob(directory, job, "preflight_blocked")
|
|
1085
1165
|
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1166
|
+
await reschedule()
|
|
1086
1167
|
return
|
|
1087
1168
|
}
|
|
1088
1169
|
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
@@ -1095,6 +1176,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1095
1176
|
await writeState(directory, sessionID, state)
|
|
1096
1177
|
await notifyJob(directory, job, "preflight_failed")
|
|
1097
1178
|
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1179
|
+
await reschedule()
|
|
1098
1180
|
return
|
|
1099
1181
|
}
|
|
1100
1182
|
}
|
|
@@ -1118,16 +1200,18 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1118
1200
|
const fresh = await readState(directory, sessionID)
|
|
1119
1201
|
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1120
1202
|
await writeState(directory, sessionID, fresh)
|
|
1203
|
+
await reschedule()
|
|
1204
|
+
return
|
|
1121
1205
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
}
|
|
1206
|
+
let timer
|
|
1207
|
+
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)
|
|
1208
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1209
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1127
1210
|
} catch (error) {
|
|
1128
1211
|
clearActiveRun(sessionID)
|
|
1129
1212
|
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
1130
1213
|
await appendLoopLog(directory, "error", { sessionID, job: job.name || job.id, error: error instanceof Error ? error.message : String(error) })
|
|
1214
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1131
1215
|
}
|
|
1132
1216
|
}
|
|
1133
1217
|
|
|
@@ -1171,6 +1255,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
1171
1255
|
|
|
1172
1256
|
state.jobs.push(parsed.job)
|
|
1173
1257
|
await writeState(directory, sessionID, state)
|
|
1258
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1174
1259
|
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
1175
1260
|
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
1176
1261
|
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
@@ -1178,11 +1263,18 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
1178
1263
|
|
|
1179
1264
|
async function stopLoop(directory, client, sessionID, args) {
|
|
1180
1265
|
const target = String(args || "").trim()
|
|
1181
|
-
if (!target || target.toLowerCase() === "all") {
|
|
1266
|
+
if (!target || target.toLowerCase() === "all") {
|
|
1267
|
+
await removeState(directory, sessionID)
|
|
1268
|
+
clearActiveRun(sessionID)
|
|
1269
|
+
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
1270
|
+
await toast(client, "All loops stopped for this session.", "success")
|
|
1271
|
+
return
|
|
1272
|
+
}
|
|
1182
1273
|
const state = await readState(directory, sessionID)
|
|
1183
1274
|
const before = state.jobs.length
|
|
1184
1275
|
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
1185
1276
|
await writeState(directory, sessionID, state)
|
|
1277
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1186
1278
|
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
1187
1279
|
}
|
|
1188
1280
|
|
|
@@ -1192,6 +1284,7 @@ async function updateJobState(directory, client, sessionID, args, updater, messa
|
|
|
1192
1284
|
let count = 0
|
|
1193
1285
|
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
1194
1286
|
await writeState(directory, sessionID, state)
|
|
1287
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1195
1288
|
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
1196
1289
|
}
|
|
1197
1290
|
|
|
@@ -1215,6 +1308,7 @@ async function pauseGoal(directory, client, sessionID, args) {
|
|
|
1215
1308
|
let count = 0
|
|
1216
1309
|
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
1217
1310
|
await writeState(directory, sessionID, state)
|
|
1311
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1218
1312
|
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1219
1313
|
}
|
|
1220
1314
|
|
|
@@ -1229,7 +1323,10 @@ async function resumeGoal(directory, client, sessionID, args) {
|
|
|
1229
1323
|
})
|
|
1230
1324
|
await writeState(directory, sessionID, state)
|
|
1231
1325
|
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1232
|
-
if (count)
|
|
1326
|
+
if (count) {
|
|
1327
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1328
|
+
scheduleIdleWork(directory, client, sessionID)
|
|
1329
|
+
}
|
|
1233
1330
|
}
|
|
1234
1331
|
|
|
1235
1332
|
async function clearGoal(directory, client, sessionID, args) {
|
|
@@ -1238,6 +1335,7 @@ async function clearGoal(directory, client, sessionID, args) {
|
|
|
1238
1335
|
const before = state.jobs.length
|
|
1239
1336
|
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
1240
1337
|
await writeState(directory, sessionID, state)
|
|
1338
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1241
1339
|
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
1242
1340
|
}
|
|
1243
1341
|
|