diff --git a/.github/workflows/bundle-gate.yml b/.github/workflows/bundle-gate.yml index 51d00dbc..59762d41 100644 --- a/.github/workflows/bundle-gate.yml +++ b/.github/workflows/bundle-gate.yml @@ -45,6 +45,9 @@ jobs: - name: Build a single-file stable plugin from source run: npm run build:plugin + - name: Require committed stable bundle to match modular source + run: git diff --exit-code -- src/index.js + - name: Verify generated entry syntax and import run: | node --check src/index.js @@ -66,4 +69,4 @@ jobs: run: npm ci - name: Run generated-bundle regression suite - run: npm test \ No newline at end of file + run: npm test diff --git a/README.md b/README.md index 6d2eac26..298ddb0f 100644 --- a/README.md +++ b/README.md @@ -1,330 +1,261 @@ # OpenCode Loop -**Claude Code-style auto-continue, scheduled OpenCode work, and background loops.** +**Idle-safe auto-continue, scheduled OpenCode work, and background loops.** -OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, safe long-running continuation helpers, and the `opencode-loopd` background daemon. +OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon. -> **Current release: `0.5.33`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below. +> **Current stable release: `0.5.33`.** The scheduling changes documented on this branch are planned for the next release. Loop still includes an older experimental `/loop-goal` mode for compatibility; for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin. ## Install or update -Choose either installation method below. - -### Option 1 — one-command install with `npx` (recommended) +Recommended: ```bash npx -y @bybrawe/opencode-loop@latest ``` -Run the same command again whenever you want to update. If **OpenCode Goals is already installed**, a normal Loop install/update also makes a best-effort call to the official `@bybrawe/opencode-goal@latest` installer so the companion Goal plugin does not silently remain on an older release. - -Install Loop and Goals together from scratch with: +Run the same command again to update. Then **fully restart OpenCode** and verify: -```bash -npx -y @bybrawe/opencode-loop@latest --with-goals +```text +/loop-help +/loop-doctor ``` -If you use the dedicated OpenCode Goals plugin and do not want Loop's older experimental `/loop-goal*` commands in the slash-command list, use: +Install/update Loop and the dedicated Goals companion together: ```bash npx -y @bybrawe/opencode-loop@latest --with-goals --without-loop-goals ``` -`--without-loop-goals` removes only Loop's packaged experimental `/loop-goal*` command files for that install/update. It keeps `/loop`, `/loop-command`, `/loop-shell`, the Loop runtime, and the separate OpenCode Goals `/goal` workflow. The choice is not persisted: run the flag again on a later Loop update if you want to keep those legacy command files omitted. +`--without-loop-goals` removes only Loop's older experimental `/loop-goal*` command files. It keeps normal `/loop`, command/shell scheduling, daemon support, and the separate `/goal` plugin. -Update/install only Loop and skip all Goals companion network work with: +Install only Loop and skip companion network work: ```bash npx -y @bybrawe/opencode-loop@latest --loop-only ``` -`--with-goals` is explicit and fails if the requested Goals install/update fails. The automatic refresh of an already-installed Goals companion is best-effort: Loop still finishes its own update and prints a retry command if the companion registry/install step is temporarily unavailable. - -### Option 2 — install with npm - -Install OpenCode Loop globally so its installer and daemon commands are available: +Global npm alternative: ```bash npm install -g @bybrawe/opencode-loop@latest opencode-loop ``` -To update later: +To uninstall: ```bash -npm install -g @bybrawe/opencode-loop@latest -opencode-loop +npx -y @bybrawe/opencode-loop@latest --uninstall ``` -If you want the global Loop installer to install Goals too, run: +or, for a global install: ```bash -opencode-loop --with-goals +opencode-loop --uninstall +npm uninstall -g @bybrawe/opencode-loop ``` -For the cleaner command surface with the dedicated Goals plugin: - -```bash -opencode-loop --with-goals --without-loop-goals -``` +Project Loop state under `.opencode/opencode-loop/` is intentionally preserved by uninstall. -`npm install @bybrawe/opencode-loop` by itself only adds the Node package to the current project. For a normal OpenCode installation, use the global npm method above or the recommended `npx` installer. +## The mental model -The installer: +Loop separates two ideas: -- installs/updates the OpenCode Loop plugin; -- installs the `/loop-*` slash-command definitions; -- installs the tool-denied, hidden `opencode-loop-local` acknowledgement subagent used only by Loop's bound local command definitions; -- keeps an existing npm plugin entry pinned to the installer’s exact version to avoid stale OpenCode package-cache resolution; -- removes duplicate old local Loop plugin copies when the package entry is authoritative; -- refreshes OpenCode Goals through **Goals' own official installer** when an existing managed Goals installation is detected; -- can explicitly install both packages with `--with-goals`, skip companion network work with `--loop-only`, or omit the packaged experimental `/loop-goal*` command files with `--without-loop-goals`. +1. **When does work become due?** Idle, a timer, a watch trigger, or `/loop-now`. +2. **When is it safe to dispatch?** Only when the session is actually idle and no active tool/child work would overlap it. -Then **fully restart OpenCode** and verify: +A timer expiring does **not** intentionally inject a second model turn on top of an active one. Due work waits for idle. -```text -/loop-help -/loop-doctor -``` +## Quick start -If Goals is installed too, also verify: +### Keep saying “continue” whenever the assistant stops ```text -/goal status +/loop continue ``` -### Uninstall - -If you use the `npx` installer: +Turkish shorthand works naturally too: -```bash -npx -y @bybrawe/opencode-loop@latest --uninstall +```text +/loop devam et ``` -If you installed OpenCode Loop globally with npm: +Explicit equivalent: -```bash -opencode-loop --uninstall -npm uninstall -g @bybrawe/opencode-loop +```text +/loop idle continue ``` -Run `opencode-loop --uninstall` before removing the global npm package so it can clean its OpenCode registrations and managed command files. +This is an **unlimited idle loop** by default. When the assistant finishes and the session becomes safely idle, Loop sends the prompt again. When that turn finishes, it does the same again, until you pause/stop it or a configured limit is reached. -Uninstall removes known OpenCode Loop package registrations, local plugin files, `/loop-*` command markdown files, and the Loop local command agent while preserving unrelated OpenCode configuration. It **does not uninstall OpenCode Goals**; Goals remains separately managed by its own installer. +For very short continuation prompts such as `continue`, `keep going`, or `devam et`, Loop adds project-continuation guidance: treat the turn as continuation of the current repository/conversation, inspect relevant files/TODO/progress/git state as needed, find the next unfinished step, avoid redoing completed work, and verify meaningful changes when practical. -Project Loop state is intentionally preserved under: +For project work, a strong default is: ```text -.opencode/opencode-loop/ +/loop --safe --ask-never --progress-file progress.md devam et ``` -Delete that directory yourself only when you intentionally want to erase saved Loop jobs, logs, checkpoints, or related local state. Restart OpenCode after uninstalling. - -## Quick start - -Auto-continue whenever OpenCode becomes idle: +Create a starter progress file first if needed: ```text -/loop 0s continue from progress.md and implement the next unfinished TODO +/loop-init ``` -Run a prompt every 5 minutes when idle: +### Repeat every 5 minutes ```text -/loop 5m continue the project +/loop every 5m continue the project ``` -Wait before the first run: +This waits five minutes before the first run, then becomes due every five minutes. If the timer expires while OpenCode is busy, it waits for the next safe idle boundary rather than stacking prompts. + +### Do it once after 5 minutes ```text -/loop 5m --no-now continue the project +/loop after 5m continue once ``` -Schedule an OpenCode command such as compaction: +Alias: ```text -/loop-command 200m /compact +/loop in 5m continue once ``` -Run a real shell command on a schedule: +This is a one-shot delayed job. Five minutes passing makes it due; it still waits for idle before dispatching. + +### Legacy compact syntax remains supported ```text -/loop-shell 10m npm test +/loop 5m continue the project ``` -Ask a recurring quality-control question: +For backward compatibility, this form starts on the next safe idle boundary and then follows a five-minute interval. + +Delay its first run explicitly: ```text -/loop-ask 1h did you run tests, typecheck, and build? If not, run them and fix errors. +/loop 5m --no-now continue the project ``` -## What OpenCode Loop is for - -Use Loop when the important question is **“what should run again, and when?”** +Legacy idle form is still valid: -Typical uses: - -- Claude Code-style auto-continue after idle; -- recurring coding prompts; -- progress.md / TODO workflows; -- scheduled `/compact` or other OpenCode commands; -- recurring tests/lint/build shell jobs; -- retry/fix loops with verification; -- patch checkpoints; -- maximum run/runtime/failure limits; -- prompt-file workflows; -- background work through `opencode-loopd`; -- Windows Task Scheduler integration. +```text +/loop 0s continue +``` -Loop is idle-safe: if a job becomes due while the session is busy, active tools or child work are still running, the job waits instead of intentionally stacking another turn on top of the current one. +For the exact schedule truth table and edge cases, see [docs/SCHEDULING.md](./docs/SCHEDULING.md). -## For stronger persistent Goals, use OpenCode Goals +## Schedule summary -OpenCode Loop still includes the older **experimental** `/loop-goal` workflow for backward compatibility unless you install/update with `--without-loop-goals`. It is useful for compatibility and lightweight outcome-driven automation, but it is not the strongest Goal implementation in this project family. +| Command | Meaning | First dispatch | Repeats | +|---|---|---|---| +| `/loop continue` | auto-continue on idle | next safe idle | every idle | +| `/loop idle continue` | explicit idle loop | next safe idle | every idle | +| `/loop every 5m continue` | recurring timer | after 5m, then idle | yes | +| `/loop after 5m continue` | delayed one-shot | after 5m, then idle | no | +| `/loop in 5m continue` | delayed one-shot alias | after 5m, then idle | no | +| `/loop 5m continue` | legacy recurring form | next safe idle | yes | +| `/loop 5m --no-now continue` | legacy delayed-first recurring | after 5m, then idle | yes | +| `/loop 0s continue` | legacy idle form | next safe idle | every idle | -For durable Goal Contracts, host-owned evidence, semantic verification, native Todo coordination, revision isolation, false-completion protection, restart recovery, Goal audit, budgets, and ordered Goals, install **OpenCode Goals**. +## Understand an unfamiliar project and keep going -Convenient combined installer from Loop: +A useful first loop for an unfamiliar repository is: -```bash -npx -y @bybrawe/opencode-loop@latest --with-goals +```text +/loop-init +/loop --safe --ask-never --progress-file progress.md Understand the existing project architecture and current state first. Inspect relevant source, tests, docs, TODOs, git status, and recent work. Record useful state in progress.md, choose the next unfinished safe improvement, implement it, verify it, update progress.md, and continue from there on later idle turns. ``` -For new installations where the dedicated `/goal` workflow replaces Loop's older experimental Goal commands, prefer: +Once the project state is established, this is enough: -```bash -npx -y @bybrawe/opencode-loop@latest --with-goals --without-loop-goals +```text +/loop --safe --ask-never --progress-file progress.md devam et ``` -Or install Goals directly with its standalone installer: +The short continuation form is deliberately interpreted as “resume this project”, not “invent a new task from scratch”. -```bash -npx -y @bybrawe/opencode-goal@latest -``` +## Other job types -Or install its CLI globally with npm and run the installer: +Schedule an OpenCode slash command: -```bash -npm install -g @bybrawe/opencode-goal@latest -opencode-goal +```text +/loop-command 200m /compact ``` -Then use: +Run a real shell command: ```text -/goal -/goal status -/goal contract -/goal audit +/loop-shell 10m npm test ``` -### Can Loop and Goals be installed together? - -Yes. `@bybrawe/opencode-loop` and `@bybrawe/opencode-goal` use different package names, commands, and project-state directories, so they can coexist in one OpenCode installation. - -Recommended split: - -- use **`/goal`** for persistent, strongly verified outcome completion; -- use **`/loop`**, `/loop-command`, `/loop-shell`, and `opencode-loopd` for scheduling/repetition/background infrastructure. - -Do **not** run Loop’s `/loop-goal` and OpenCode Goals `/goal` against the same work in the same session. Both can autonomously continue on idle boundaries and can compete to start turns. Use `--without-loop-goals` when you want to remove that legacy command surface entirely. - -Likewise, avoid leaving a prompt-producing `/loop ...` job continuously injecting agent turns into a session while an OpenCode Goal is actively continuing. Use separate sessions or pause/remove that prompt loop until the Goal is done. Scheduled shell/command jobs should also be chosen carefully so they do not race files or verification. - -Install both with one `npx` command: +Ask a recurring quality-control prompt: -```bash -npx -y @bybrawe/opencode-loop@latest --with-goals +```text +/loop-ask 1h did you run tests, typecheck, and build? If not, run them and fix errors. ``` -Or install both globally with npm, then run the Loop installer with the companion flag: +Watch a path: -```bash -npm install -g @bybrawe/opencode-loop@latest @bybrawe/opencode-goal@latest -opencode-loop --with-goals +```text +/loop --watch progress.md inspect the updated progress and continue ``` -Once Goals is managed in the OpenCode config, future normal `opencode-loop` / `npx ...opencode-loop@latest` updates also attempt to refresh Goals to its latest stable release. Use `--loop-only` when you intentionally do not want that companion refresh. Use `--without-loop-goals` on any install/update where you want the older Loop Goal command files omitted. +Prompt, command, shell, compact, and watch jobs use the same idle-safe scheduling path but different execution transports. ## Core commands | Command | Purpose | |---|---| -| `/loop ` | Add/update an idle/interval prompt loop | -| `/loop-command ` | Schedule OpenCode slash commands | +| `/loop ` | Add/update an unlimited idle prompt loop | +| `/loop idle ` | Explicit idle-loop form | +| `/loop every ` | Recurring timer, first run delayed | +| `/loop after ` | One-shot delayed prompt | +| `/loop in ` | Alias for `after` | +| `/loop ` | Backward-compatible compact interval form | +| `/loop-command ` | Schedule an OpenCode command | | `/loop-cmd ` | Alias for `/loop-command` | -| `/loop-ask ` | Schedule recurring quality/check prompts | -| `/loop-shell ` | Schedule a real shell command | -| `/loop-status` | Show active jobs | -| `/loop-now [id/name/all]` | Run selected jobs immediately | +| `/loop-ask ` | Schedule a recurring check/question | +| `/loop-shell ` | Schedule a shell command | +| `/loop-status` | Show jobs plus schedule/state | +| `/loop-now [id/name/all]` | Mark selected jobs due now; still waits for idle | | `/loop-pause [id/name/all]` | Pause jobs | | `/loop-resume [id/name/all]` | Resume jobs | | `/loop-remove [id/name/all]` | Remove jobs | -| `/loop-clear` | Remove all current-session Loop jobs | -| `/loop-doctor` | Diagnose Loop/OpenCode state | +| `/loop-clear` | Remove all jobs for the current session | +| `/loop-logs` | Show recent scheduler/runtime events | +| `/loop-doctor` | Diagnose session/state/scheduling problems | | `/loop-init` | Create a starter `progress.md` | -| `/loop-export` | Export Loop state as JSON | -| `/loop-goal ` | Start the older experimental Loop Goal Mode when those optional legacy command files are installed | - -## Intervals and job types - -Examples: - -```text -0s run whenever OpenCode becomes idle -5m run every 5 minutes when idle -200m run every 200 minutes when idle -1h run every hour when idle -``` - -OpenCode Loop separates prompt, slash-command, and shell work because they should not be executed the same way. - -| Type | Example | -|---|---| -| Prompt | `/loop 0s continue from progress.md` | -| Scheduled question | `/loop-ask 1h did you run tests?` | -| OpenCode command | `/loop-command 200m /compact` | -| Shell | `/loop-shell 10m npm test` | - -Do not use a normal prompt loop when you mean to run `/compact`. Prefer: - -```text -/loop-command 200m /compact -``` +| `/loop-export` | Export current-session Loop state | ## Useful flags -### Naming and lifecycle - -```text -/loop 0s --name dev continue the project -/loop-pause dev -/loop-resume dev -/loop-stop dev -``` - -### Limits +Lifecycle and limits: ```text +--name --max-runs --max-runtime --max-failures --timeout +--no-now +--now ``` -### Verification and safety +Safety and verification: ```text +--safe +--ask-never +--no-overlap --verify "npm test" --preflight "npm install" --postrun "git status --short" --pause-on-verify-fail ---safe ---ask-never ---no-overlap ``` -### Context and TODO workflows +Project context: ```text --progress-file progress.md @@ -335,119 +266,137 @@ Do not use a normal prompt loop when you mean to run `/compact`. Prefer: --watch progress.md ``` -### Checkpoints +Checkpoints: ```text --checkpoint-only --git-checkpoint ``` -`--git-checkpoint` may stage and commit work. Use it only when that behavior is intentional. +`--git-checkpoint` may stage/commit work; use it only when intentional. -## Recommended development loop +## Status and diagnostics + +`/loop-status` reports both the **schedule definition** and the **current scheduler state**. Examples: ```text -/loop 0s --name dev --ask-never --safe --no-overlap --batch 5 --compact-every 200m --checkpoint-only --progress-file progress.md Treat progress.md as the project state. Continue with the next unfinished TODO, implement it, mark completed items, add useful follow-up TODOs, run tests/lint/build when available, and keep going while work remains. +schedule=every idle | state=waiting for idle +schedule=every 5m, first after 5m | state=due in 3m +schedule=once after 5m | state=due; waiting for idle ``` -## Test-fix loop +This is intentional: “due” is a clock fact, while “waiting for idle” is an admission/safety fact. + +### If a job says enabled but never runs + +The problematic shape is typically: ```text -/loop 0s --name testfix --ask-never --safe --verify "npm test" --max-failures 3 Continue from progress.md. If tests fail, analyze the failure, fix it, and run the tests again. +enabled=true +paused=false +runCount=0 +lastRunAt=0 ``` -## Background daemon +Some OpenCode TUI versions can leave `session.status` at `busy`/`retry` after a plugin command acknowledgement. Loop cross-checks that stale state against the chronological message tail before the first Loop run too. It recovers only when the latest assistant message is actually completed and no active tool/child work is known. A genuinely unfinished assistant turn, latest user turn, active tool, busy child, or unknown completion remains busy. -The normal `/loop` plugin is session-bound. If OpenCode closes, that TUI/session loop cannot keep running in the background. `opencode-loopd` resolves the session once at startup and pins that exact session for later iterations, so a newer unrelated session cannot steal the daemon. If no session exists, the daemon creates one and pins it before the second iteration. Each daemon run is bounded by `--timeout` (30 minutes by default; use `--timeout 0s` to disable). +Recovery is logged as: -For long-running background jobs use: - -```bash -opencode-loopd --project . --every 5m --prompt-file loop-prompt.md +```text +status-message-idle-recovery ``` -Run immediately on each daemon iteration: +Busy retries are also written as throttled `deferred` events so `loop.log` does not misleadingly contain only the original `add` line. -```bash -opencode-loopd --project . --every 0s --prompt "continue from progress.md and implement the next unfinished TODO" +Use: + +```text +/loop-status +/loop-logs +/loop-doctor ``` -Select a model and agent: +### Session-bound state -```bash -opencode-loopd --project . --every 0s --max-runs 1 --timeout 30m --model provider/model --agent build --prompt-file loop-prompt.md +Normal plugin Loop jobs are session-bound and stored under: + +```text +.opencode/opencode-loop/.json ``` -Pin a specific existing session when needed: +A new OpenCode session does not silently inherit another session's jobs. `/loop-doctor` reports other persisted session files that still contain enabled jobs, including jobs that have never run, so an old loop is visible instead of appearing lost. -```bash -opencode-loopd --project . --session ses_xxx --every 5m --prompt-file loop-prompt.md -``` +For work that must continue after the TUI/session closes, use `opencode-loopd`. -Limit total runs: +## Loop and dedicated OpenCode Goals -```bash -opencode-loopd --project . --every 5m --max-runs 20 --prompt-file loop-prompt.md -``` +They can be installed together. Recommended split: -## Windows Task Scheduler +- use **`/goal`** for durable outcome-driven work, evidence, verification, restart recovery, and semantic completion; +- use **`/loop`**, `/loop-command`, `/loop-shell`, and `opencode-loopd` for scheduling/repetition/background infrastructure. -Install a scheduled daemon task: +A prompt-producing Loop and an active dedicated `/goal` should not both own autonomous continuation of the **same session**. The runtime therefore blocks a new prompt Loop when it detects an active dedicated Goal for that session. -```powershell -opencode-loopd install-task --project "C:\path\to\project" --every 10m --prompt-file loop-prompt.md --name OpenCodeLoop +Recommended choices are to finish/pause the Goal or use a separate session. Advanced users can deliberately override the guard with: + +```text +/loop --allow-goal-overlap continue ``` -Remove it: +That escape hatch can create competing autonomous turns; use it only when that is intentional. -```powershell -opencode-loopd uninstall-task --name OpenCodeLoop -``` +Loop's older experimental `/loop-goal*` commands remain available for compatibility unless installed with `--without-loop-goals`. For new strongly verified Goal work, prefer: -## Experimental Loop Goal Mode +```bash +npx -y @bybrawe/opencode-goal@latest +``` -Loop’s original Goal Mode remains available for existing users by default. If you install/update Loop with `--without-loop-goals`, its packaged `/loop-goal*` command definitions are removed while the rest of Loop keeps working. +## Recommended development loop ```text -/loop-goal --check "npm test" --complete-when-checks-pass fix the failing tests +/loop --name dev --ask-never --safe --no-overlap --batch 5 --compact-every 200m --checkpoint-only --progress-file progress.md Treat progress.md as the project state. Continue with the next unfinished TODO, implement it, mark completed items, add useful follow-up TODOs, run tests/lint/build when available, and keep going while work remains. ``` -Controls: +Test/fix loop: ```text -/loop-goal-status -/loop-goal-pause -/loop-goal-resume -/loop-goal-clear -/loop-goal-done -/loop-goal-blocked +/loop --name testfix --ask-never --safe --verify "npm test" --max-failures 3 Continue from progress.md. If tests fail, analyze the failure, fix it, and run the tests again. ``` -It supports acceptance criteria, check commands, evidence text, no-progress guards, turn/runtime limits, and reports under: +## Background daemon -```text -.opencode/opencode-loop/goals/ +The normal `/loop` plugin is session-bound. `opencode-loopd` pins one exact OpenCode session for daemon iterations and can keep scheduling outside the normal interactive Loop timer lifecycle. + +```bash +opencode-loopd --project . --every 5m --prompt-file loop-prompt.md ``` -For new work where completion integrity matters, prefer the dedicated **OpenCode Goals** package instead: +Immediate daemon cadence: ```bash -npx -y @bybrawe/opencode-goal@latest +opencode-loopd --project . --every 0s --prompt "continue from progress.md and implement the next unfinished TODO" ``` -or: +Pin a session and limit runs: ```bash -npm install -g @bybrawe/opencode-goal@latest -opencode-goal +opencode-loopd --project . --session ses_xxx --every 5m --max-runs 20 --timeout 30m --prompt-file loop-prompt.md +``` + +Windows Task Scheduler: + +```powershell +opencode-loopd install-task --project "C:\path\to\project" --every 10m --prompt-file loop-prompt.md --name OpenCodeLoop +opencode-loopd uninstall-task --name OpenCodeLoop ``` ## State and checkpoints -Loop runtime state: +Runtime state and logs: ```text .opencode/opencode-loop/ +.opencode/opencode-loop/loop.log ``` Patch checkpoints: @@ -456,62 +405,27 @@ Patch checkpoints: .opencode/opencode-loop/checkpoints/ ``` -Recent plugin events: +Add `.opencode/opencode-loop/` to `.gitignore` if runtime state should not be committed. -```text -.opencode/opencode-loop/loop.log -``` +## Permissions -Add `.opencode/opencode-loop/` to `.gitignore` when you do not want runtime noise committed. +For unattended work, avoid permanently allowing every shell command. Grant routine read/edit/test operations while keeping destructive commands on ask/deny. `--safe` reduces risk but does not replace OpenCode permission configuration. -## Suggested OpenCode permissions - -For unattended loops, avoid granting every shell command permanently. Allow routine read/edit/test operations and keep destructive operations on ask/deny. - -Example: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "read": "allow", - "grep": "allow", - "glob": "allow", - "todowrite": "allow", - "edit": "allow", - "bash": { - "*": "ask", - "git status*": "allow", - "git diff*": "allow", - "npm test*": "allow", - "npm run test*": "allow", - "npm run lint*": "allow", - "git push*": "ask", - "git reset*": "ask", - "git clean*": "deny", - "rm *": "deny" - }, - "external_directory": "ask" - } -} -``` - -`--safe` reduces risk but does not replace careful OpenCode permission configuration. - -## Package +## Package and compatibility ```text @bybrawe/opencode-loop ``` -The package also installs: +Stable package peer range: -```bash -opencode-loop --help -opencode-loopd --help +```text +@opencode-ai/plugin >=1.4.0 <2 ``` -See [CHANGELOG.md](./CHANGELOG.md) for release history. +The repository contains experimental OpenCode 2 work, but the stable package does not claim full OpenCode 2 parity. + +See [docs/SCHEDULING.md](./docs/SCHEDULING.md) for the scheduling contract and [CHANGELOG.md](./CHANGELOG.md) for release history. ## License diff --git a/commands/loop.md b/commands/loop.md index c6daa252..f31b009f 100644 --- a/commands/loop.md +++ b/commands/loop.md @@ -1,5 +1,5 @@ --- -description: Start an OpenCode auto-continue loop. Usage: /loop 5m +description: "Auto-continue or schedule work. /loop = every idle; /loop every 5m = recurring; /loop after 5m = once." agent: opencode-loop-local --- diff --git a/docs/SCHEDULING.md b/docs/SCHEDULING.md new file mode 100644 index 00000000..3006fc5a --- /dev/null +++ b/docs/SCHEDULING.md @@ -0,0 +1,296 @@ +# OpenCode Loop scheduling semantics + +Loop is an **idle-safe continuation and scheduling layer**. It is not intended to stack model turns while OpenCode is already working. + +The key rule is simple: + +> A job may become **due** because of idle state, a timer, a watch trigger, or `/loop-now`, but a prompt/command/shell action is dispatched only when the session is safe to use. + +That means a timer expiring does **not** imply a second assistant turn is injected on top of a busy turn. Due work waits for idle. + +## The four useful forms + +### 1. Continue forever whenever the assistant stops + +```text +/loop continue +``` + +or: + +```text +/loop devam et +``` + +or explicitly: + +```text +/loop idle continue +``` + +This is the normal Claude Code-style continuation loop. + +Semantics: + +- schedule mode: `idle` +- first run: next safe idle boundary +- later runs: every later safe idle boundary +- run limit: unlimited unless `--max-runs` / `--max-runtime` is supplied +- overlap: disabled by default + +If the assistant finishes a turn, Loop sends the prompt again. If that next turn finishes, Loop sends it again, and so on until the job is paused/stopped, a configured limit is reached, or the host/session goes away. + +Short continuation prompts such as `continue`, `keep going`, and `devam et` receive extra project-continuation guidance. The agent is told to treat the turn as continuation of the existing conversation/repository, inspect relevant files/TODO/progress/git state as needed, choose the next unfinished step, and avoid redoing completed work. + +For a real project, a stronger version is: + +```text +/loop --safe --ask-never --progress-file progress.md devam et +``` + +Initialize the progress file first if needed: + +```text +/loop-init +``` + +### 2. Repeat every N minutes + +```text +/loop every 5m continue the project +``` + +Semantics: + +- schedule mode: `interval` +- first run: after 5 minutes +- later runs: every 5 minutes according to the Loop due clock +- if due while OpenCode is busy: wait until idle, then run once +- missed busy intervals are not intentionally stacked into multiple prompts + +Use this when you really mean a recurring timer. + +You can still use the older compact form: + +```text +/loop 5m continue the project +``` + +For backward compatibility, the compact form starts on the next safe idle boundary and then uses the 5-minute interval. To delay the first legacy-form run, use: + +```text +/loop 5m --no-now continue the project +``` + +The explicit `every 5m` form is recommended because its first-run behavior is obvious. + +### 3. Do something once after a delay + +```text +/loop after 5m continue once +``` + +Alias: + +```text +/loop in 5m continue once +``` + +Semantics: + +- schedule mode: `once` +- first/only due time: 5 minutes after creation +- dispatch: only when the session is idle +- run limit: one + +If the five minutes expire while a model/tool/subtask is still running, the one-shot job waits. It does not interrupt the active turn. + +The legacy equivalent is roughly: + +```text +/loop 5m --no-now --max-runs 1 continue once +``` + +### 4. Run when a watched path changes + +```text +/loop --watch progress.md inspect the new progress and continue +``` + +Watch jobs remain dormant until their watch condition is triggered, then use the same idle-safe dispatch path. + +## Schedule truth table + +| Command | Mode | First dispatch | Repeats? | +|---|---|---|---| +| `/loop continue` | idle | next safe idle | yes, every idle | +| `/loop idle continue` | idle | next safe idle | yes, every idle | +| `/loop every 5m continue` | interval | after 5m, then first safe idle | yes | +| `/loop after 5m continue` | once | after 5m, then first safe idle | no | +| `/loop in 5m continue` | once | after 5m, then first safe idle | no | +| `/loop 5m continue` | legacy interval | next safe idle | yes | +| `/loop 5m --no-now continue` | legacy interval | after 5m, then first safe idle | yes | +| `/loop 0s continue` | legacy idle | next safe idle | yes, every idle | + +## What “idle-safe” means + +Before dispatching a Loop-owned turn, the runtime checks: + +1. no Loop run is already being dispatched for the session; +2. OpenCode is not reporting a live running turn that still has unfinished assistant output; +3. no active tool call is known for the session; +4. no busy descendant/subtask session is known; +5. `noOverlap` / active-run guards allow another turn; +6. the job is still enabled, unpaused, and within its configured limits. + +If any of those checks fail, the job remains due and Loop retries later. + +## Stale `busy` recovery + +Some OpenCode TUI builds can leave `session.status` at `busy` or `retry` after a plugin command acknowledgement even though the assistant message is already completed. This can otherwise produce the classic symptom: + +```text +Loop added +runCount = 0 +lastRunAt = 0 +``` + +The runtime now cross-checks stale live status with the chronological session tail **before the first Loop run too**. + +Recovery is conservative: + +- latest assistant tail has a real completion timestamp -> stale busy may be recovered to idle; +- latest assistant tail is unfinished -> remain busy; +- latest message is user/non-assistant -> remain busy; +- active tool or busy child session -> remain busy; +- unknown completion -> remain busy. + +So Loop can recover a stale host status without treating a genuinely running turn as finished. + +A recovery is written to `loop.log` as: + +```text +status-message-idle-recovery +``` + +## Busy deferral logging + +When a due job cannot run because the session is still busy, Loop now emits throttled diagnostics instead of silently leaving only the original `add` line. + +Typical event: + +```text +deferred reason=session-busy source=due +``` + +The log is throttled so a 5-second busy retry does not flood `loop.log`. + +Inspect recent events with: + +```text +/loop-logs +``` + +## `/loop-status` + +Status now separates the schedule definition from its current state. + +Examples: + +```text +schedule=every idle | state=waiting for idle +schedule=every 5m, first after 5m | state=due in 3m +schedule=once after 5m | state=due; waiting for idle +``` + +This distinction is important: **due** is a timing fact; **waiting for idle** is an admission/safety fact. + +## `/loop-doctor` and session-bound jobs + +Normal plugin Loop jobs are session-bound and persist under: + +```text +.opencode/opencode-loop/.json +``` + +Starting a new OpenCode session does not move old Loop jobs into the new session. + +`/loop-doctor` reports: + +- current session ID; +- number of current-session jobs; +- whether a dedicated `/goal` state is detected for the same session; +- other persisted session state files that still contain enabled jobs; +- how many of those jobs have never run. + +This makes an old enabled job visible instead of looking like the current session mysteriously lost it. + +For work that must keep running after the TUI/session closes, use `opencode-loopd`; the normal plugin scheduler is intentionally session-bound. + +## Dedicated `/goal` coexistence + +Loop and OpenCode Goals can be installed together, but they should not both own autonomous continuation of the same session. + +If dedicated `/goal` is currently `active`, Loop blocks a new prompt-producing `/loop` job in that same session by default: + +```text +Prompt loop not added: dedicated /goal already owns continuation in this session. +``` + +Recommended choices: + +- let `/goal` own autonomous continuation and use Loop only in another session; +- pause/finish the Goal before starting a prompt Loop; +- use scheduled shell/command work only when you understand possible file/verification races. + +An explicit escape hatch exists for advanced use: + +```text +/loop --allow-goal-overlap continue +``` + +Use that only when duplicate autonomous turn ownership is intentional. + +## Recommended “understand this project and keep going” workflow + +For an unfamiliar repository: + +```text +/loop-init +``` + +Then: + +```text +/loop --safe --ask-never --progress-file progress.md Understand the existing project architecture and current state first. Inspect the relevant source, tests, docs, TODOs, git status, and recent work. Record the useful state in progress.md, choose the next unfinished safe improvement, implement it, verify it, update progress.md, and continue from there on later idle turns. +``` + +After the project state is established, a short continuation loop is enough: + +```text +/loop --safe --ask-never --progress-file progress.md devam et +``` + +Because `devam et` is recognized as continuation shorthand, later turns are instructed to resume the existing project rather than start a new interpretation from scratch. + +## Stopping and limits + +Idle loops are intentionally unlimited by default. Bound them when needed: + +```text +/loop --max-runs 20 continue +/loop --max-runtime 6h continue +/loop every 5m --max-failures 3 continue +``` + +Control commands: + +```text +/loop-pause +/loop-resume +/loop-stop +/loop-clear +/loop-now +``` + +`/loop-now` marks a job due immediately but still uses the idle-safe scheduler; it does not re-enter the model from inside the control-command hook. diff --git a/package.json b/package.json index 4a3b82fa..78549db7 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool", "build:plugin:npm": "npm run build:plugin", "prepack": "node --check src/index.js", - "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", - "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", + "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", + "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs", "install:global": "node scripts/install-with-goals.mjs", "pack:zip": "node scripts/make-zip.mjs" diff --git a/scripts/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index 1b08697d..0d4604b8 100644 --- a/scripts/comprehensive-test.mjs +++ b/scripts/comprehensive-test.mjs @@ -243,7 +243,7 @@ async function testParserAndPresets() { assert.match(preset.action, /Test command hint: npm run ci/) const toastCount = h.records.toasts.length - await h.command("loop", "nonsense") + await h.command("loop", "every nope continue") await h.command("loop", "5m") assert.equal(h.records.toasts.length, toastCount + 2) assert.ok(h.records.toasts.slice(-2).every((item) => item.variant === "warning")) diff --git a/scripts/host-loop-canary.mjs b/scripts/host-loop-canary.mjs index 8c284161..e5f4a9b3 100644 --- a/scripts/host-loop-canary.mjs +++ b/scripts/host-loop-canary.mjs @@ -10,7 +10,7 @@ import { fileURLToPath, pathToFileURL } from "node:url" const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const isWindows = process.platform === "win32" -const LOOP_OBJECTIVE = "real host loop canary" +const LOOP_OBJECTIVE = "devam et" const RUN_NOW_NATURAL_OBJECTIVE = "real host loop-now natural canary" const RUN_NOW_TARGET_OBJECTIVE = "real host loop-now target canary" @@ -332,7 +332,9 @@ async function main() { signal: AbortSignal.timeout(timeoutMs), }) - const command = sendCommand("loop", `0s --max-runs 3 ${LOOP_OBJECTIVE}`).catch((error) => { + // No duration token on purpose: this proves `/loop devam et` is the real idle-loop + // shorthand on a live OpenCode host, not only a parser-unit-test behavior. + const command = sendCommand("loop", `--max-runs 3 ${LOOP_OBJECTIVE}`).catch((error) => { commandError = error return null }) @@ -356,7 +358,10 @@ async function main() { try { persisted = JSON.parse(await readFile(stateFile, "utf8")) } catch {} if (persisted?.jobs?.length) { const loop = persisted.jobs.find((item) => item.name === "default" || item.action === LOOP_OBJECTIVE) - if (loop) assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(loop)}`) + if (loop) { + assert.equal(loop.scheduleMode, "idle", `durationless continuation must persist as idle mode: ${JSON.stringify(loop)}`) + assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(loop)}`) + } } await sendCommand("loop", `10m --no-now --name natural --multi --max-runs 1 ${RUN_NOW_NATURAL_OBJECTIVE}`) diff --git a/scripts/loop-command-handlers-test.mjs b/scripts/loop-command-handlers-test.mjs index e7456182..5e4fd86a 100644 --- a/scripts/loop-command-handlers-test.mjs +++ b/scripts/loop-command-handlers-test.mjs @@ -53,6 +53,8 @@ function harness(initialStates = {}, overrides = {}) { appendLoopLog: async (...args) => { logs.push(args) }, readFile: overrides.readFile || (async () => { throw new Error("missing") }), writeFile: async (...args) => { fileWrites.push(args) }, + listPersistedLoopSessions: overrides.listPersistedLoopSessions || (async () => []), + findDedicatedGoalForSession: overrides.findDedicatedGoalForSession || (async () => undefined), runtimeVersion: "v-test", runtimePlatform: "test-platform", }) @@ -148,7 +150,9 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD const text = h.messages[0][2] assert.match(text, /OpenCode loop status:/) assert.match(text, /1\. dev \(dev\): dev: 10s \[prompt\] -> Work on dev/) - assert.match(text, /runs=3 \| failures=1 \| due in 5s/) + assert.match(text, /schedule=every 10s, starts on next idle/) + assert.match(text, /state=due in 5s/) + assert.match(text, /runs=3 \| failures=1/) assert.match(text, /active,safe,ask-never,no-overlap/) assert.match(text, /goal:blocked,paused,checkpoint-only,git-checkpoint/) } @@ -166,7 +170,19 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD }, }, { now: () => 10_000 }) await h.handlers.statusLoop("/work", {}, sessionID) - assert.match(h.messages[0][2], /due in 5s/, "--no-now status must count the first interval from createdAt") + assert.match(h.messages[0][2], /state=due in 5s/, "--no-now status must count the first interval from createdAt") +} + +{ + const sessionID = "status-idle" + const h = harness({ + [sessionID]: { + jobs: [loopJob("continue", { intervalMs: 0, lastRunAt: 0, scheduleMode: "idle", action: "continue" })], + }, + }) + await h.handlers.statusLoop("/work", {}, sessionID) + assert.match(h.messages[0][2], /schedule=every idle/) + assert.match(h.messages[0][2], /state=waiting for idle/) } { @@ -199,6 +215,9 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD await h.handlers.helpLoop({}, "help") const text = h.messages[0][2] assert.match(text, /OpenCode Loop help:/) + assert.match(text, /\/loop continue the project/) + assert.match(text, /\/loop every 5m continue the project/) + assert.match(text, /\/loop after 5m continue the project/) assert.match(text, /\/loop-goal finish the feature/) assert.match(text, /\/loop-doctor \| \/loop-init \| \/loop-export/) } @@ -231,13 +250,23 @@ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelD { const sessionID = "doctor" - const h = harness({ [sessionID]: { jobs: [loopJob("a"), loopJob("b")] } }) + const h = harness({ [sessionID]: { jobs: [loopJob("a"), loopJob("b")] } }, { + listPersistedLoopSessions: async () => [ + { sessionID, current: true, jobs: 2, enabled: 2, neverRan: 0 }, + { sessionID: "other-session", current: false, jobs: 1, enabled: 1, neverRan: 1 }, + ], + findDedicatedGoalForSession: async () => ({ id: "goal-abcdef123456", status: "active" }), + }) await h.handlers.doctorLoop("/repo", {}, sessionID) const text = h.messages[0][2] assert.match(text, /OpenCode Loop doctor:/) assert.match(text, /- plugin: opencode-loop/) assert.match(text, /- project directory: \/repo/) - assert.match(text, /- active jobs: 2/) + assert.match(text, /- current session: doctor/) + assert.match(text, /- current-session jobs: 2/) + assert.match(text, /- dedicated \/goal: active/) + assert.match(text, /- other persisted sessions with enabled jobs: 1/) + assert.match(text, /other session other-session: jobs=1, enabled=1, never-ran=1/) assert.match(text, /- node: v-test/) assert.match(text, /- platform: test-platform/) assert.match(text, /experimental goal smoke test/) diff --git a/scripts/loop-registration-test.mjs b/scripts/loop-registration-test.mjs index 7a430bc6..0b820b8f 100644 --- a/scripts/loop-registration-test.mjs +++ b/scripts/loop-registration-test.mjs @@ -11,6 +11,7 @@ function harness(initialStates = {}, overrides = {}) { const messages = [] const logs = [] const contexts = new Map(Object.entries(overrides.contexts || {})) + const dedicatedGoals = new Map(Object.entries(overrides.dedicatedGoals || {})) const { addLoop } = createLoopRegistration({ snapshotPaths: async (directory, files) => { @@ -30,6 +31,7 @@ function harness(initialStates = {}, overrides = {}) { appendLoopLog: async (...args) => { logs.push(args) }, normalizedModelRef: (value) => value ? `normalized:${value}` : undefined, getSessionExecutionContext: (sessionID) => contexts.get(sessionID), + findDedicatedGoalForSession: async (_directory, sessionID) => dedicatedGoals.get(sessionID), defaultActiveGuardMs: overrides.defaultActiveGuardMs ?? 45_000, }) @@ -50,7 +52,7 @@ assert.equal(sameLoopDefinition(null, {}), false) const sessionID = "invalid" const h = harness() const client = {} - await h.addLoop("/work", client, sessionID, "not-a-duration and no defaults") + await h.addLoop("/work", client, sessionID, "every nope continue") assert.equal(h.writes.length, 0) assert.equal(h.due.length, 0) assert.equal(h.idle.length, 0) @@ -58,7 +60,59 @@ assert.equal(sameLoopDefinition(null, {}), false) assert.equal(h.toasts.length, 1) assert.equal(h.toasts[0][0], client) assert.equal(h.toasts[0][2], "warning") - assert.match(h.toasts[0][1], /Usage: \/loop/) + assert.match(h.toasts[0][1], /Invalid every schedule/) +} + +{ + const sessionID = "shorthand-idle" + const h = harness() + await h.addLoop("/work", {}, sessionID, "continue the project") + const job = h.states.get(sessionID).jobs[0] + assert.equal(job.action, "continue the project") + assert.equal(job.intervalMs, 0) + assert.equal(job.scheduleMode, "idle") + assert.equal(job.scheduleSyntax, "idle-shorthand") + assert.equal(job.immediate, true) + assert.equal(job.maxRuns, 0) + assert.equal(h.idle.length, 1) +} + +{ + const sessionID = "explicit-idle" + const h = harness() + await h.addLoop("/work", {}, sessionID, "idle continue forever") + const job = h.states.get(sessionID).jobs[0] + assert.equal(job.action, "continue forever") + assert.equal(job.intervalMs, 0) + assert.equal(job.scheduleMode, "idle") + assert.equal(job.scheduleSyntax, "idle") +} + +{ + const sessionID = "every" + const h = harness() + await h.addLoop("/work", {}, sessionID, "every 5m continue the project") + const job = h.states.get(sessionID).jobs[0] + assert.equal(job.intervalMs, 300_000) + assert.equal(job.scheduleMode, "interval") + assert.equal(job.scheduleSyntax, "every") + assert.equal(job.immediate, false) + assert.equal(job.maxRuns, 0) + assert.equal(h.idle.length, 0) + assert.equal(h.due.length, 1) +} + +{ + const sessionID = "after" + const h = harness() + await h.addLoop("/work", {}, sessionID, "after 5m continue once") + const job = h.states.get(sessionID).jobs[0] + assert.equal(job.intervalMs, 300_000) + assert.equal(job.scheduleMode, "once") + assert.equal(job.scheduleSyntax, "after") + assert.equal(job.immediate, false) + assert.equal(job.maxRuns, 1) + assert.equal(h.idle.length, 0) } { @@ -73,6 +127,7 @@ assert.equal(sameLoopDefinition(null, {}), false) assert.equal(job.model, "ctx-model") assert.equal(job.activeRecoveryMs, 45_000) assert.equal(job.immediate, true) + assert.equal(job.scheduleMode, "idle") assert.equal(h.writes.length, 1) assert.deepEqual(h.due, [["/work", client, sessionID]]) assert.deepEqual(h.idle, [["/work", client, sessionID]]) @@ -139,6 +194,25 @@ assert.equal(sameLoopDefinition(null, {}), false) assert.equal(job.activeRecoveryMs, 180_000) } +{ + const sessionID = "dedicated-goal-conflict" + const h = harness({}, { dedicatedGoals: { [sessionID]: { id: "goal-123", status: "active", sessionID } } }) + await h.addLoop("/work", {}, sessionID, "continue the same work") + assert.equal(h.writes.length, 0) + assert.equal(h.due.length, 0) + assert.equal(h.idle.length, 0) + assert.equal(h.logs[0][1], "goal-overlap-blocked") + assert.match(h.toasts[0][1], /dedicated \/goal already owns continuation/) +} + +{ + const sessionID = "dedicated-goal-override" + const h = harness({}, { dedicatedGoals: { [sessionID]: { id: "goal-123", status: "active", sessionID } } }) + await h.addLoop("/work", {}, sessionID, "--allow-goal-overlap continue intentionally") + assert.equal(h.writes.length, 1) + assert.equal(h.states.get(sessionID).jobs[0].allowGoalOverlap, true) +} + { const sessionID = "long-interval" const h = harness() @@ -195,7 +269,7 @@ assert.equal(sameLoopDefinition(null, {}), false) const sessionID = "dry-run" const client = {} const h = harness() - await h.addLoop("/work", client, sessionID, "0s --dry-run inspect only") + await h.addLoop("/work", client, sessionID, "--dry-run inspect only") assert.equal(h.writes.length, 0) assert.equal(h.due.length, 0) assert.equal(h.idle.length, 0) diff --git a/scripts/schedule-policy-test.mjs b/scripts/schedule-policy-test.mjs new file mode 100644 index 00000000..e9a5b857 --- /dev/null +++ b/scripts/schedule-policy-test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict" +import { + dueJobs, + inferredScheduleMode, + jobDueAt, + jobIsDue, + nextDueDelay, + scheduleDescription, + scheduleState, +} from "../src/source/runtime/schedule-policy.js" + +const current = 1_000_000 +const base = { + id: "job", + enabled: true, + paused: false, + maxRuns: 0, + runCount: 0, + intervalMs: 0, + immediate: true, + lastRunAt: 0, + createdAt: new Date(current - 10_000).toISOString(), +} + +{ + const job = { ...base, scheduleMode: "idle" } + assert.equal(inferredScheduleMode(job), "idle") + assert.equal(jobDueAt(job, current), current) + assert.equal(jobIsDue(job, current), true) + assert.equal(scheduleDescription(job), "every idle") + assert.equal(scheduleState(job, current), "waiting for idle") +} + +{ + const job = { + ...base, + scheduleMode: "interval", + intervalMs: 300_000, + immediate: false, + createdAt: new Date(current).toISOString(), + } + assert.equal(jobDueAt(job, current), current + 300_000) + assert.equal(jobIsDue(job, current), false) + assert.equal(scheduleDescription(job), "every 5m, first after 5m") + assert.equal(scheduleState(job, current), "due in 5m") +} + +{ + const job = { + ...base, + scheduleMode: "interval", + intervalMs: 300_000, + immediate: true, + } + assert.equal(jobDueAt(job, current), current) + assert.equal(scheduleDescription(job), "every 5m, starts on next idle") +} + +{ + const job = { + ...base, + scheduleMode: "once", + intervalMs: 300_000, + immediate: false, + maxRuns: 1, + createdAt: new Date(current).toISOString(), + } + assert.equal(inferredScheduleMode(job), "once") + assert.equal(scheduleDescription(job), "once after 5m") + assert.equal(jobIsDue(job, current + 299_999), false) + assert.equal(jobIsDue(job, current + 300_000), true) + const completed = { ...job, runCount: 1 } + assert.equal(jobDueAt(completed, current + 300_000), Infinity) +} + +{ + const watch = { ...base, watchPaths: ["progress.md"], watchTriggered: false } + assert.equal(inferredScheduleMode(watch), "watch") + assert.equal(jobDueAt(watch, current), Infinity) + assert.equal(jobDueAt({ ...watch, watchTriggered: true }, current), current) +} + +{ + const expired = { + ...base, + intervalMs: 3_600_000, + immediate: false, + maxRuntimeMs: 5_000, + createdAt: new Date(current - 6_000).toISOString(), + } + assert.equal(jobDueAt(expired, current), current, "expired max-runtime must wake admission so it can remove the job") +} + +{ + const dueNow = { ...base, id: "normal" } + const targeted = { ...base, id: "targeted", runNowRequestedAt: current } + const state = { jobs: [dueNow, targeted] } + assert.deepEqual(dueJobs(state, current).map((job) => job.id), ["targeted", "normal"]) + assert.equal(nextDueDelay(state, current), 0) +} + +{ + const delayed = { + ...base, + intervalMs: 60_000, + immediate: false, + createdAt: new Date(current).toISOString(), + } + assert.equal(nextDueDelay({ jobs: [delayed] }, current), 60_000) + assert.equal(jobIsDue({ ...delayed, paused: true }, current + 60_000), false) + assert.equal(jobIsDue({ ...delayed, enabled: false }, current + 60_000), false) +} + +console.log("schedule policy tests passed") diff --git a/scripts/schedule-syntax-test.mjs b/scripts/schedule-syntax-test.mjs new file mode 100644 index 00000000..92904884 --- /dev/null +++ b/scripts/schedule-syntax-test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { normalizeLoopScheduleArgs } from "../src/source/core/schedule-syntax.js" + +{ + const result = normalizeLoopScheduleArgs("continue the project") + assert.equal(result.ok, true) + assert.equal(result.args, "continue the project") + assert.equal(result.defaults.intervalMs, 0) + assert.equal(result.defaults.immediate, true) + assert.equal(result.scheduleMode, "idle") + assert.equal(result.scheduleSyntax, "idle-shorthand") +} + +{ + const result = normalizeLoopScheduleArgs("--safe --ask-never continue the project") + assert.equal(result.ok, true) + assert.equal(result.defaults.intervalMs, 0) + assert.equal(result.args, "--safe --ask-never continue the project") +} + +{ + const result = normalizeLoopScheduleArgs("idle continue forever") + assert.equal(result.args, "continue forever") + assert.equal(result.defaults.intervalMs, 0) + assert.equal(result.defaults.immediate, true) + assert.equal(result.scheduleMode, "idle") +} + +{ + const result = normalizeLoopScheduleArgs("every 5m continue forever") + assert.equal(result.args, "continue forever") + assert.equal(result.defaults.intervalMs, 300_000) + assert.equal(result.defaults.immediate, false) + assert.equal(result.scheduleMode, "interval") + assert.equal(result.scheduleSyntax, "every") +} + +{ + const result = normalizeLoopScheduleArgs("after 5m continue once") + assert.equal(result.args, "continue once") + assert.equal(result.defaults.intervalMs, 300_000) + assert.equal(result.defaults.immediate, false) + assert.equal(result.defaults.maxRuns, 1) + assert.equal(result.scheduleMode, "once") +} + +{ + const result = normalizeLoopScheduleArgs("in 30s ping once") + assert.equal(result.args, "ping once") + assert.equal(result.defaults.intervalMs, 30_000) + assert.equal(result.defaults.maxRuns, 1) + assert.equal(result.scheduleSyntax, "after") +} + +{ + const result = normalizeLoopScheduleArgs("5m continue legacy") + assert.equal(result.args, "5m continue legacy") + assert.equal(result.scheduleMode, "interval") + assert.equal(result.scheduleSyntax, "legacy") +} + +{ + const result = normalizeLoopScheduleArgs("--allow-goal-overlap continue") + assert.equal(result.args, "continue") + assert.equal(result.allowGoalOverlap, true) + assert.equal(result.scheduleMode, "idle") +} + +{ + const result = normalizeLoopScheduleArgs("every nope continue") + assert.equal(result.ok, false) + assert.match(result.error, /Invalid every schedule/) +} + +console.log("schedule syntax tests passed") diff --git a/scripts/session-status-idle-recovery-test.mjs b/scripts/session-status-idle-recovery-test.mjs new file mode 100644 index 00000000..08a4d353 --- /dev/null +++ b/scripts/session-status-idle-recovery-test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { clearSessionActivity } from "../src/source/runtime/session-activity.js" +import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js" + +const activeRuns = new Map() +const logs = [] +let clock = 100_000 +let completion = "completed" + +const runtime = createSessionStatusRuntime({ + activeRuns, + now: () => clock, + sessionStatusCacheMs: 0, + appendLoopLog: async (...args) => logs.push(args), + activeRunCompletionFromMessages: async () => completion, +}) + +const busyClient = { + session: { + status: async () => ({ data: { session: { type: "busy" } } }), + }, +} + +try { + runtime.markSessionStatus("session", "busy", clock - 2_000) + assert.equal(await runtime.sessionStatusType(busyClient, "session", "/repo"), "idle") + assert.equal(logs.at(-1)[1], "status-message-idle-recovery") + assert.equal(logs.at(-1)[2].staleStatus, "busy") + + runtime.clearSessionStatus("session") + completion = "incomplete" + clock += 5_000 + assert.equal(await runtime.sessionStatusType(busyClient, "session", "/repo"), "busy", "an unfinished tail must never be force-recovered") + + runtime.clearSessionStatus("session") + completion = "unknown" + clock += 5_000 + assert.equal(await runtime.sessionStatusType(busyClient, "session", "/repo"), "busy", "unknown tail completion must remain conservative") + + console.log("session status idle recovery tests passed") +} finally { + activeRuns.clear() + clearSessionActivity("session") +} diff --git a/src/index.js b/src/index.js index fb407544..6a232140 100644 --- a/src/index.js +++ b/src/index.js @@ -1021,6 +1021,27 @@ function commandArgsText(args) { return String(args); } +// src/source/core/continuation.js +var CONTINUATION_SHORTHANDS = new Set([ + "continue", + "continue.", + "continue working", + "keep going", + "go on", + "devam", + "devam et", + "devam et.", + "devam et bakal\u0131m" +]); +function isContinuationShorthand(value) { + return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " ")); +} +function continuationProjectInstruction(value) { + if (!isContinuationShorthand(value)) + return ""; + 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."; +} + // src/source/core/jobs.js function presetDefaults(name) { if (name === "loop-compact") @@ -1086,6 +1107,9 @@ function actionKind(action, job = {}) { } function decoratePrompt(job) { const additions = []; + const continuation = continuationProjectInstruction(job.action); + if (continuation) + additions.push(continuation); 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.`); if (job.lastVerifyFailure) @@ -1623,8 +1647,185 @@ function createGoalCommandHandlers(options = {}) { } // src/source/opencode/loop-commands.js +import { promises as fs6 } from "fs"; +import path7 from "path"; + +// src/source/runtime/companion-goal.js import { promises as fs4 } from "fs"; import path5 from "path"; +function goalRoot(directory) { + return path5.join(directory, ".opencode", "goals"); +} +async function findDedicatedGoalForSession(directory, sessionID) { + if (!directory || !sessionID) + return; + let names; + try { + names = await fs4.readdir(goalRoot(directory)); + } catch (error) { + if (error?.code === "ENOENT") + return; + return; + } + for (const name of names) { + if (!name.endsWith(".json")) + continue; + try { + const value = JSON.parse(await fs4.readFile(path5.join(goalRoot(directory), name), "utf8")); + if (value?.sessionID === sessionID) + return value; + } catch {} + } + return; +} +function dedicatedGoalOwnsContinuation(goal) { + return goal?.status === "active"; +} +function dedicatedGoalSummary(goal) { + if (!goal) + return "not detected"; + const id = String(goal.id || "unknown").slice(0, 12); + const status = String(goal.status || "unknown"); + return `${status} (${id})`; +} + +// src/source/runtime/loop-diagnostics.js +import { promises as fs5 } from "fs"; +import path6 from "path"; + +// src/source/runtime/schedule-policy.js +var TERMINAL_GOAL_STATUSES = new Set(["completed", "blocked", "cleared"]); +function inferredScheduleMode(job) { + const explicit = String(job?.scheduleMode || "").toLowerCase(); + if (["idle", "interval", "once", "watch"].includes(explicit)) + return explicit; + if (job?.watchPaths?.length) + return "watch"; + if (Number(job?.maxRuns || 0) === 1 && job?.immediate === false && Number(job?.intervalMs || 0) > 0) + return "once"; + return Number(job?.intervalMs || 0) === 0 ? "idle" : "interval"; +} +function jobRunnable(job) { + if (!job) + return false; + if (isGoalJob(job) && TERMINAL_GOAL_STATUSES.has(job.goalStatus)) + return false; + if (!job.enabled || job.paused) + return false; + if (Number(job.maxRuns || 0) > 0 && Number(job.runCount || 0) >= Number(job.maxRuns || 0)) + return false; + return true; +} +function jobDueAt(job, current = Date.now()) { + if (!jobRunnable(job)) + return Infinity; + if (Number(job.runNowRequestedAt || 0) > 0) + return current; + const created = Date.parse(job.createdAt || ""); + if (Number(job.maxRuntimeMs || 0) > 0 && Number.isFinite(created) && current - created >= Number(job.maxRuntimeMs || 0)) + return current; + if (job.watchPaths?.length) + return job.watchTriggered === true ? current : Infinity; + const intervalMs = Number(job.intervalMs || 0); + if (intervalMs === 0) + return current; + const lastRunAt = Number(job.lastRunAt || 0); + if (!lastRunAt) { + if (job.immediate === false) + return (Number.isFinite(created) ? created : current) + intervalMs; + return current; + } + return lastRunAt + intervalMs; +} +function jobIsDue(job, current = Date.now(), force = false) { + if (!jobRunnable(job)) + return false; + if (force) + return true; + return jobDueAt(job, current) <= current; +} +function dueJobs(state, current = Date.now(), force = false) { + return (state?.jobs || []).filter((job) => jobIsDue(job, current, force)).sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0)); +} +function nextDueDelay(state, current = Date.now()) { + let soonest = Infinity; + for (const job of state?.jobs || []) + soonest = Math.min(soonest, jobDueAt(job, current)); + if (!Number.isFinite(soonest)) + return Infinity; + return Math.max(0, soonest - current); +} +function scheduleDescription(job) { + const mode = inferredScheduleMode(job); + const intervalMs = Number(job?.intervalMs || 0); + if (mode === "idle") + return "every idle"; + if (mode === "watch") + return `on watch: ${(job.watchPaths || []).join(", ")}`; + if (mode === "once") + return intervalMs > 0 ? `once after ${durationToText(intervalMs)}` : "once on next idle"; + if (job?.immediate === false) + return `every ${durationToText(intervalMs)}, first after ${durationToText(intervalMs)}`; + return `every ${durationToText(intervalMs)}, starts on next idle`; +} +function scheduleState(job, current = Date.now()) { + if (!job?.enabled) + return "stopped"; + if (job?.paused) + return "paused"; + if (Number(job?.runNowRequestedAt || 0) > 0) + return "due now; waiting for idle"; + const mode = inferredScheduleMode(job); + const dueAt = jobDueAt(job, current); + if (!Number.isFinite(dueAt)) + return mode === "watch" ? "waiting for watched change" : "not scheduled"; + if (dueAt <= current) + return mode === "idle" ? "waiting for idle" : "due; waiting for idle"; + return `due in ${durationToText(dueAt - current)}`; +} + +// src/source/runtime/loop-diagnostics.js +function describeJobScheduling(job, current = Date.now()) { + return { + schedule: scheduleDescription(job), + state: scheduleState(job, current) + }; +} +async function listPersistedLoopSessions(directory, currentSessionID) { + const root = stateDir(directory); + let names; + try { + names = await fs5.readdir(root); + } catch (error) { + if (error?.code === "ENOENT") + return []; + return []; + } + const sessions = []; + for (const name of names) { + if (!name.endsWith(".json")) + continue; + const sessionID = name.slice(0, -5); + try { + const parsed = JSON.parse(await fs5.readFile(path6.join(root, name), "utf8")); + const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : []; + const enabled = jobs.filter((job) => job?.enabled !== false && !job?.paused).length; + const neverRan = jobs.filter((job) => Number(job?.runCount || 0) === 0).length; + sessions.push({ + sessionID, + current: sessionID === currentSessionID, + jobs: jobs.length, + enabled, + neverRan + }); + } catch { + sessions.push({ sessionID, current: sessionID === currentSessionID, jobs: 0, enabled: 0, neverRan: 0, corrupt: true }); + } + } + return sessions.sort((a, b) => Number(b.current) - Number(a.current) || b.enabled - a.enabled || a.sessionID.localeCompare(b.sessionID)); +} + +// src/source/opencode/loop-commands.js var SERVICE2 = "opencode-loop"; var DEFAULT_PROGRESS_MD = `# Progress @@ -1671,8 +1872,10 @@ function createLoopCommandHandlers(options = {}) { const removeState2 = typeof options.removeState === "function" ? options.removeState : removeState; const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists; const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog; - const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs4.readFile(...args); - const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs4.writeFile(...args); + const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs6.readFile(...args); + const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs6.writeFile(...args); + const listPersistedLoopSessions2 = typeof options.listPersistedLoopSessions === "function" ? options.listPersistedLoopSessions : listPersistedLoopSessions; + const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession; const runtimeVersion = options.runtimeVersion || process.version; const runtimePlatform = options.runtimePlatform || process.platform; async function stopLoop(directory, client, sessionID, args) { @@ -1704,15 +1907,11 @@ function createLoopCommandHandlers(options = {}) { async function statusLoop(directory, client, sessionID) { const state = await readState2(directory, sessionID); const jobs = state.jobs || []; + const current = now2(); const lines = jobs.length ? jobs.map((job, index) => { - const current = now2(); - const intervalMs = Number(job.intervalMs || 0); - const lastRunAt = Number(job.lastRunAt || 0); - const createdAt = Date.parse(job.createdAt || ""); - const dueAt = Number(job.runNowRequestedAt || 0) > 0 ? current : lastRunAt > 0 ? lastRunAt + intervalMs : job.immediate === false && Number.isFinite(createdAt) ? createdAt + intervalMs : current; - const dueIn = Math.max(0, dueAt - current); + const scheduling = describeJobScheduling(job, current); 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(","); - return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`; + 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}`; }) : ["No active loop jobs."]; await toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning"); await say2(client, sessionID, `OpenCode loop status: @@ -1722,7 +1921,7 @@ function createLoopCommandHandlers(options = {}) { async function logsLoop(directory, client, sessionID) { let text = "No loop log found."; try { - text = (await readFile(path5.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(` + text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(` `) || text; } catch {} await say2(client, sessionID, `OpenCode loop logs: @@ -1731,18 +1930,20 @@ function createLoopCommandHandlers(options = {}) { async function helpLoop(client, sessionID) { await say2(client, sessionID, [ "OpenCode Loop help:", - "/loop 0s Claude Code style auto-continue", - "/loop 5m --ask-never --safe interval autonomous prompt loop", - "/loop-command 200m /compact OpenCode slash-command loop, waits for idle", - "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt", - "/loop-shell 10m npm test shell loop, waits for idle", - "/loop-goal finish the feature and keep tests green experimental persistent goal mode", - '/loop-goal --check "npm run build" --check "npm test" --complete-when-checks-pass ship it', - "/loop-goal status | pause | resume | clear manage experimental goals", - "/loop 200m --command /compact same as command loop", - '/loop 0s --verify "npm test" verify after each assistant turn', - "/loop 0s --prompt-file loop-prompt.md load prompt from a file", - "/loop 0s --max-runtime 6h --max-failures 3 stop safely after limits", + "/loop continue the project auto-continue forever whenever the session becomes idle", + "/loop idle continue the project explicit form of the same idle loop", + "/loop every 5m continue the project recurring timer; first run after 5m, always waits for idle", + "/loop after 5m continue the project one-shot delayed prompt; runs once when 5m has passed and session is idle", + "/loop 5m continue the project legacy compact form: starts on next idle, then every 5m", + "/loop 5m --no-now continue the project legacy recurring form with first run delayed 5m", + "/loop-command 200m /compact OpenCode slash-command loop, waits for idle", + "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt", + "/loop-shell 10m npm test shell loop, waits for idle", + "/loop-goal finish the feature and keep tests green experimental persistent goal mode", + '/loop 0s --verify "npm test" verify after each assistant turn', + "/loop --prompt-file loop-prompt.md idle loop loading its prompt from a file", + "/loop 0s --max-runtime 6h --max-failures 3 stop safely after limits", + "Prompt-producing /loop jobs are blocked while dedicated /goal owns the same session; use another session or --allow-goal-overlap only intentionally.", "/loop-doctor | /loop-init | /loop-export" ].join(` `)); @@ -1767,22 +1968,36 @@ function createLoopCommandHandlers(options = {}) { } async function doctorLoop(directory, client, sessionID) { const state = await readState2(directory, sessionID); - await say2(client, sessionID, [ + const persisted = await listPersistedLoopSessions2(directory, sessionID); + const otherSessions = persisted.filter((entry) => !entry.current && entry.enabled > 0); + const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID); + const lines = [ "OpenCode Loop doctor:", `- plugin: ${SERVICE2}`, `- project directory: ${directory}`, `- state directory: ${stateDir(directory)}`, - `- active jobs: ${(state.jobs || []).length}`, + `- current session: ${sessionID}`, + `- current-session jobs: ${(state.jobs || []).length}`, + `- dedicated /goal: ${dedicatedGoalSummary(dedicatedGoal)}`, + `- other persisted sessions with enabled jobs: ${otherSessions.length}`, `- node: ${runtimeVersion}`, `- platform: ${runtimePlatform}`, - "- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md", + "- smoke test: /loop --max-runs 1 --dry-run continue from progress.md", + "- delayed smoke test: /loop after 5m --dry-run continue from progress.md", + "- recurring smoke test: /loop every 5m --dry-run continue from progress.md", "- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it" - ].join(` + ]; + for (const entry of otherSessions.slice(0, 8)) { + lines.push(`- other session ${entry.sessionID}: jobs=${entry.jobs}, enabled=${entry.enabled}, never-ran=${entry.neverRan}`); + } + if (otherSessions.length > 8) + lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`); + await say2(client, sessionID, lines.join(` `)); } async function initLoop(directory, client, sessionID, args) { const target = String(args || "").trim() || "progress.md"; - const full = path5.resolve(directory, target); + const full = path7.resolve(directory, target); if (await pathExists2(full)) { await toast2(client, `${target} already exists.`, "warning"); return; @@ -1808,6 +2023,81 @@ function createLoopCommandHandlers(options = {}) { }; } +// src/source/core/schedule-syntax.js +function removeBooleanFlag(input, flag) { + const pattern = new RegExp(`(^|\\s)${flag.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}(?=\\s|$)`, "i"); + const found = pattern.test(input); + return { + found, + value: String(input || "").replace(pattern, " ").replace(/\s+/g, " ").trim() + }; +} +function firstToken(input) { + const match = String(input || "").trim().match(/^(\S+)(?:\s+([\s\S]*))?$/); + return match ? { token: match[1], rest: String(match[2] || "").trim() } : { token: "", rest: "" }; +} +function inferredMode(intervalMs, maxRuns) { + if (Number(maxRuns || 0) === 1 && Number(intervalMs || 0) > 0) + return "once"; + return Number(intervalMs || 0) === 0 ? "idle" : "interval"; +} +function normalizeLoopScheduleArgs(raw, defaults = {}) { + const overlap = removeBooleanFlag(String(raw || "").trim(), "--allow-goal-overlap"); + let input = overlap.value; + const nextDefaults = { ...defaults }; + let scheduleMode = defaults.scheduleMode; + let scheduleSyntax = "legacy"; + const first = firstToken(input); + const keyword = first.token.toLowerCase(); + if (keyword === "idle") { + nextDefaults.intervalMs = 0; + nextDefaults.immediate = true; + scheduleMode = "idle"; + scheduleSyntax = "idle"; + input = first.rest; + } else if (keyword === "every" || keyword === "after" || keyword === "in") { + const duration = firstToken(first.rest); + const intervalMs = parseDuration(duration.token); + if (intervalMs === null) { + return { + ok: false, + error: `Invalid ${keyword} schedule. Example: /loop ${keyword === "every" ? "every" : "after"} 5m continue the project` + }; + } + nextDefaults.intervalMs = intervalMs; + nextDefaults.immediate = false; + input = duration.rest; + if (keyword === "every") { + scheduleMode = intervalMs === 0 ? "idle" : "interval"; + scheduleSyntax = "every"; + } else { + nextDefaults.maxRuns = 1; + scheduleMode = "once"; + scheduleSyntax = "after"; + } + } else { + const duration = parseDuration(first.token); + if (duration !== null) { + scheduleMode = duration === 0 ? "idle" : "interval"; + } else if (nextDefaults.intervalMs === undefined || nextDefaults.intervalMs === null) { + nextDefaults.intervalMs = 0; + nextDefaults.immediate = nextDefaults.immediate ?? true; + scheduleMode = "idle"; + scheduleSyntax = "idle-shorthand"; + } else { + scheduleMode = scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns); + } + } + return { + ok: true, + args: input, + defaults: nextDefaults, + scheduleMode: scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns), + scheduleSyntax, + allowGoalOverlap: overlap.found || defaults.allowGoalOverlap === true + }; +} + // src/source/opencode/loop-registration.js var DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180000; var FALLBACK_ACTIVE_GUARD_MS = 45000; @@ -1831,19 +2121,34 @@ function createLoopRegistration(options = {}) { const toast2 = requireFunction4(options.toast, "toast"); const say2 = requireFunction4(options.say, "say"); const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs; + const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs; const readState2 = typeof options.readState === "function" ? options.readState : readState; const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState; const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog; const normalizedModelRef2 = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : normalizedModelRef; const getSessionExecutionContext2 = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : getSessionExecutionContext; + const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession; const configuredGuard = Number(options.defaultActiveGuardMs); const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS; async function addLoop(directory, client, sessionID, args, defaults = {}) { - const parsed = parseLoopArgs2(args, defaults); + const normalized = normalizeLoopScheduleArgs2(args, defaults); + if (!normalized.ok) { + await toast2(client, normalized.error, "warning"); + return; + } + const parsed = parseLoopArgs2(normalized.args, normalized.defaults); if (!parsed.ok) { await toast2(client, parsed.error, "warning"); return; } + parsed.job.scheduleMode = normalized.scheduleMode; + parsed.job.scheduleSyntax = normalized.scheduleSyntax; + parsed.job.allowGoalOverlap = normalized.allowGoalOverlap === true; + if (normalized.scheduleSyntax === "after") { + parsed.job.immediate = false; + parsed.job.maxRuns = 1; + parsed.job.lastRunAt = Date.parse(parsed.job.createdAt || "") || Date.now(); + } const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" }; parsed.job.agent = defaults.agent || executionContext.agent || "build"; parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model; @@ -1863,6 +2168,19 @@ function createLoopRegistration(options = {}) { if (!parsed.job.activeRecoveryMs) { parsed.job.activeRecoveryMs = isGoalJob(parsed.job) ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS : Math.max(defaultActiveGuardMs, Math.min(90000, (parsed.job.intervalMs || 0) + 1e4)); } + const promptProducing = actionKind(parsed.job.action, parsed.job) === "prompt"; + if (promptProducing && !parsed.job.allowGoalOverlap) { + const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID); + if (dedicatedGoalOwnsContinuation(dedicatedGoal)) { + await appendLoopLog2(directory, "goal-overlap-blocked", { + sessionID, + job: parsed.job.name || parsed.job.id, + goal: dedicatedGoal.id + }); + await toast2(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"); + return; + } + } if (parsed.job.dryRun) { await toast2(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say2(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); @@ -1890,7 +2208,13 @@ function createLoopRegistration(options = {}) { if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID); await toast2(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success"); - await appendLoopLog2(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) }); + await appendLoopLog2(directory, replaced ? "replace" : "add", { + sessionID, + job: parsed.job.name || parsed.job.id, + label: jobLabel(parsed.job), + scheduleMode: parsed.job.scheduleMode, + scheduleSyntax: parsed.job.scheduleSyntax + }); } return { addLoop }; } @@ -2007,6 +2331,37 @@ function clearSessionActivity(sessionID) { deleteSessionExecutionContext(sessionID); } +// src/source/runtime/scheduler-diagnostics.js +var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000; +function createSchedulerDiagnostics(options = {}) { + const now2 = typeof options.now === "function" ? options.now : Date.now; + const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {}; + const configured = Number(options.throttleMs); + const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS; + const lastLogged = new Map; + async function logDeferral(directory, sessionID, reason, extra = {}) { + const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`; + const current = now2(); + const previous = Number(lastLogged.get(key) || 0); + if (previous > 0 && current - previous < throttleMs) + return false; + lastLogged.set(key, current); + await appendLoopLog2(directory, "deferred", { + sessionID, + reason, + ...extra + }); + return true; + } + function clearSession(sessionID) { + const prefix = `${sessionID || "unknown"}:`; + for (const key of lastLogged.keys()) + if (key.startsWith(prefix)) + lastLogged.delete(key); + } + return { logDeferral, clearSession }; +} + // src/source/runtime/scheduler.js var DEFAULT_IDLE_DEBOUNCE_MS = 1200; var DEFAULT_BUSY_RETRY_MS = 5000; @@ -2014,37 +2369,6 @@ var DEFAULT_MIN_DUE_TIMER_MS = 250; var DEFAULT_MAX_DUE_TIMER_MS = 2147000000; var DEFAULT_HEARTBEAT_MS = 2500; var DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000; -function jobDueAt(job, current = now()) { - if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) - return Infinity; - if (!job.enabled || job.paused) - return Infinity; - if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) - return Infinity; - if (Number(job.runNowRequestedAt || 0) > 0) - return current; - if (job.watchPaths?.length) - return Infinity; - const created = Date.parse(job.createdAt || ""); - if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs) - return current; - if (job.intervalMs === 0) - return current; - if (!job.lastRunAt) { - if (job.immediate === false) - return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0); - return current; - } - return job.lastRunAt + (job.intervalMs || 0); -} -function nextDueDelay(state, current = now()) { - let soonest = Infinity; - for (const job of state.jobs || []) - soonest = Math.min(soonest, jobDueAt(job, current)); - if (!Number.isFinite(soonest)) - return Infinity; - return Math.max(0, soonest - current); -} function createSchedulerRuntime(options = {}) { const idleTimers = new Map; const dueTimers = new Map; @@ -2072,6 +2396,7 @@ function createSchedulerRuntime(options = {}) { if (options.toast) await options.toast(client, message, level); }; + const diagnostics = createSchedulerDiagnostics({ appendLoopLog: appendLog, now: clock, throttleMs: options.deferralLogThrottleMs }); function stopHeartbeatIfIdle() { if (!knownSessions.size && heartbeatTimer) { clearIntervalFn(heartbeatTimer); @@ -2119,11 +2444,13 @@ function createSchedulerRuntime(options = {}) { idleTimers.delete(sessionID); Promise.resolve().then(async () => { if (!await options.sessionIsIdle?.(client, sessionID, directory)) { + await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "idle", retryMs: busyRetryMs }); await scheduleDueWork(directory, client, sessionID, busyRetryMs); return; } await options.finalizeActiveRun?.(directory, client, sessionID); if (!await options.sessionIsIdle?.(client, sessionID, directory)) { + await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "idle", retryMs: busyRetryMs }); await scheduleDueWork(directory, client, sessionID, busyRetryMs); return; } @@ -2172,11 +2499,13 @@ function createSchedulerRuntime(options = {}) { dueTimers.delete(sessionID); Promise.resolve().then(async () => { if (!await options.sessionIsIdle?.(client, sessionID, directory)) { + await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "due", retryMs: busyRetryMs }); await scheduleDueWork(directory, client, sessionID, busyRetryMs); return; } await options.finalizeActiveRun?.(directory, client, sessionID); if (!await options.sessionIsIdle?.(client, sessionID, directory)) { + await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "due", retryMs: busyRetryMs }); await scheduleDueWork(directory, client, sessionID, busyRetryMs); return; } @@ -2196,6 +2525,7 @@ function createSchedulerRuntime(options = {}) { cancelIdleWork(sessionID); cancelDueWork(sessionID); stopWatchdog(sessionID); + diagnostics.clearSession(sessionID); knownSessions.delete(sessionID); stopHeartbeatIfIdle(); } @@ -2294,8 +2624,8 @@ ${item.output}`).join(` } // src/source/runtime/job-workspace.js -import { promises as fs5 } from "fs"; -import path6 from "path"; +import { promises as fs7 } from "fs"; +import path8 from "path"; var MAX_SCAN_FILES = 200; var MAX_SCAN_BYTES = 2000000; function requireFunction6(value, name) { @@ -2330,7 +2660,7 @@ function createJobWorkspaceRuntime(options = {}) { return await buildGoalPrompt2(directory, job); const sections = []; if (job.promptFile) { - const text = await readSmallTextFile2(path6.resolve(directory, job.promptFile)); + const text = await readSmallTextFile2(path8.resolve(directory, job.promptFile)); if (text.trim()) sections.push(`Instructions from ${job.promptFile}: ${text.trim()}`); @@ -2340,7 +2670,7 @@ ${text.trim()}`); if (job.action) sections.push(decoratePrompt(job)); for (const file of job.includeFiles || []) { - const text = await readSmallTextFile2(path6.resolve(directory, file), 80000); + const text = await readSmallTextFile2(path8.resolve(directory, file), 80000); if (text.trim()) sections.push(`Context from ${file}: ${text.trim().slice(0, 20000)}`); @@ -2372,7 +2702,7 @@ ${text.trim().slice(0, 20000)}`); const snapshot = {}; for (const file of files || []) { try { - const stat = await fs5.stat(path6.resolve(directory, file)); + const stat = await fs7.stat(path8.resolve(directory, file)); snapshot[file] = `${stat.mtimeMs}:${stat.size}`; } catch { snapshot[file] = "missing"; @@ -2392,10 +2722,10 @@ ${text.trim().slice(0, 20000)}`); } async function fileContains(filePath, needle) { try { - const stat = await fs5.stat(filePath); + const stat = await fs7.stat(filePath); if (!stat.isFile() || stat.size > MAX_SCAN_BYTES) return false; - return (await fs5.readFile(filePath, "utf8")).includes(needle); + return (await fs7.readFile(filePath, "utf8")).includes(needle); } catch { return false; } @@ -2403,9 +2733,9 @@ ${text.trim().slice(0, 20000)}`); async function untilReached(directory, job) { if (!job.until) return false; - const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path6.join(".opencode", "opencode-loop", "until.txt")]; + const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path8.join(".opencode", "opencode-loop", "until.txt")]; for (const file of files) - if (await fileContains(path6.resolve(directory, file), job.until)) + if (await fileContains(path8.resolve(directory, file), job.until)) return true; let scanned = 0; async function walk(current) { @@ -2413,7 +2743,7 @@ ${text.trim().slice(0, 20000)}`); return false; let entries; try { - entries = await fs5.readdir(current, { withFileTypes: true }); + entries = await fs7.readdir(current, { withFileTypes: true }); } catch { return false; } @@ -2422,7 +2752,7 @@ ${text.trim().slice(0, 20000)}`); return false; if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue; - const full = path6.join(current, entry.name); + const full = path8.join(current, entry.name); if (entry.isDirectory()) { if (await walk(full)) return true; @@ -2446,13 +2776,13 @@ ${text.trim().slice(0, 20000)}`); if (!status.stdout.trim()) return; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const checkpointDir = path6.join(stateDir(directory), "checkpoints", safeID(sessionID)); + const checkpointDir = path8.join(stateDir(directory), "checkpoints", safeID(sessionID)); await ensureDir(checkpointDir); const diff = await runProcess2("git", ["diff", "--binary"], directory, 120000); const staged = await runProcess2("git", ["diff", "--cached", "--binary"], directory, 120000); const prefix = `${timestamp}-${safeID(job.name || job.id)}`; - await fs5.writeFile(path6.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr); - await fs5.writeFile(path6.join(checkpointDir, `${prefix}.patch`), `${diff.stdout} + await fs7.writeFile(path8.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr); + await fs7.writeFile(path8.join(checkpointDir, `${prefix}.patch`), `${diff.stdout} ${staged.stdout}`); if (job.gitCheckpoint) { await runProcess2("git", ["add", "-A"], directory, 120000); @@ -2591,6 +2921,25 @@ function createSessionStatusRuntime(options = {}) { const seenAt = sessionStatusSeenAt.get(sessionID) || 0; return cached === "idle" && seenAt > (active.startedAt || 0); } + async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) { + if (activeRuns.has(sessionID)) + return false; + if (liveType !== "busy" && liveType !== "retry") + return false; + if (!seenAt || now2() - seenAt < sessionStatusCacheMs) + return false; + const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 }); + if (completion !== "completed") + return false; + markSessionStatus(sessionID, "idle"); + await appendLoopLog2(directory, "status-message-idle-recovery", { + sessionID, + staleStatus: liveType, + statusSeenAt: seenAt, + staleForMs: Math.max(0, now2() - seenAt) + }); + return true; + } async function sessionStatusType(client, sessionID, directory, options2 = {}) { if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) { markSessionStatus(sessionID, "busy"); @@ -2604,6 +2953,8 @@ function createSessionStatusRuntime(options = {}) { return cached; const live = await readLiveSessionStatus(client, sessionID, directory); if (live?.type) { + if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt)) + return "idle"; if ((live.type === "busy" || live.type === "retry") && options2.recoverStaleActive !== false) { const active = activeRuns.get(sessionID); if (active) { @@ -2924,7 +3275,7 @@ exit=` + postrun.code + ` } // src/source/runtime/run-admission.js -import path7 from "path"; +import path9 from "path"; function requireFunction9(value, label) { if (typeof value !== "function") throw new TypeError(`createRunAdmissionRuntime requires ${label}`); @@ -2941,26 +3292,8 @@ function createRunAdmissionRuntime(options = {}) { const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob; const toast2 = typeof options.toast === "function" ? options.toast : toast; const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell; - function dueJobs(state, force = false) { - const current = now2(); - const due = (state.jobs || []).filter((job) => { - if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) - return false; - if (!job.enabled || job.paused) - return false; - if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) - return false; - if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) - return true; - if (Number(job.runNowRequestedAt || 0) > 0) - return true; - if (force) - return true; - if (job.watchPaths?.length) - return job.watchTriggered === true; - return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs; - }); - return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0)); + function dueJobs2(state, force = false) { + return dueJobs(state, now2(), force); } async function reschedule(directory, client, sessionID) { await scheduleDueWork(directory, client, sessionID); @@ -2980,7 +3313,7 @@ function createRunAdmissionRuntime(options = {}) { if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) { return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime"); } - if (job.stopFile && await pathExists2(path7.resolve(directory, job.stopFile))) { + if (job.stopFile && await pathExists2(path9.resolve(directory, job.stopFile))) { return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile); } if (await untilReached(directory, job)) { @@ -3023,7 +3356,7 @@ exit=` + preflight.code + ` } return { admitted: true, job, runNowRequested }; } - return { dueJobs, admitJob }; + return { dueJobs: dueJobs2, admitJob }; } // src/source/runtime/executor.js @@ -3125,7 +3458,7 @@ function createLoopExecutor(options = {}) { toast: toast2, dangerousShell: dangerousShell2 }); - const dueJobs = admissionRuntime.dueJobs; + const dueJobs2 = admissionRuntime.dueJobs; function clearActiveRun(sessionID) { const active = activeRuns.get(sessionID); if (active?.timer) @@ -3236,7 +3569,7 @@ function createLoopExecutor(options = {}) { candidate.watchTriggered = true; } } - const due = dueJobs(state, Boolean(runOptions.force)); + const due = dueJobs2(state, Boolean(runOptions.force)); if (!due.length) { await writeState2(directory, sessionID, state); await reschedule(); @@ -3343,7 +3676,7 @@ function createLoopExecutor(options = {}) { } } return { - dueJobs, + dueJobs: dueJobs2, clearActiveRun, disposeSession, recoverActiveDispatchFailure, diff --git a/src/source/core/continuation.js b/src/source/core/continuation.js new file mode 100644 index 00000000..ff50871d --- /dev/null +++ b/src/source/core/continuation.js @@ -0,0 +1,20 @@ +const CONTINUATION_SHORTHANDS = new Set([ + "continue", + "continue.", + "continue working", + "keep going", + "go on", + "devam", + "devam et", + "devam et.", + "devam et bakalım", +]) + +export function isContinuationShorthand(value) { + return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " ")) +} + +export function continuationProjectInstruction(value) { + if (!isContinuationShorthand(value)) return "" + 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." +} diff --git a/src/source/core/jobs.js b/src/source/core/jobs.js index da7e5376..89060744 100644 --- a/src/source/core/jobs.js +++ b/src/source/core/jobs.js @@ -1,4 +1,5 @@ import { DEFAULT_GOAL_MAX_NO_PROGRESS, parseDuration, durationToText } from "./args.js" +import { continuationProjectInstruction } from "./continuation.js" export function presetDefaults(name) { // parseLoopArgs owns duration/flag/action parsing. Presets only provide real @@ -54,6 +55,8 @@ export function actionKind(action, job = {}) { export function decoratePrompt(job) { const additions = [] + const continuation = continuationProjectInstruction(job.action) + if (continuation) additions.push(continuation) 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.`) if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200)) 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.") diff --git a/src/source/core/schedule-syntax.js b/src/source/core/schedule-syntax.js new file mode 100644 index 00000000..623b39a7 --- /dev/null +++ b/src/source/core/schedule-syntax.js @@ -0,0 +1,93 @@ +import { parseDuration } from "./args.js" + +function removeBooleanFlag(input, flag) { + const pattern = new RegExp(`(^|\\s)${flag.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}(?=\\s|$)`, "i") + const found = pattern.test(input) + return { + found, + value: String(input || "").replace(pattern, " ").replace(/\s+/g, " ").trim(), + } +} + +function firstToken(input) { + const match = String(input || "").trim().match(/^(\S+)(?:\s+([\s\S]*))?$/) + return match ? { token: match[1], rest: String(match[2] || "").trim() } : { token: "", rest: "" } +} + +function inferredMode(intervalMs, maxRuns) { + if (Number(maxRuns || 0) === 1 && Number(intervalMs || 0) > 0) return "once" + return Number(intervalMs || 0) === 0 ? "idle" : "interval" +} + +/** + * Normalize the human-facing /loop schedule grammar before the legacy flag parser. + * + * Supported forms: + * /loop -> every idle, unlimited + * /loop idle -> every idle, unlimited + * /loop every 5m -> recurring, first run after 5m + * /loop after 5m -> one shot after 5m + * /loop in 5m -> alias for `after` + * /loop 5m -> legacy compact form (starts now unless --no-now) + */ +export function normalizeLoopScheduleArgs(raw, defaults = {}) { + const overlap = removeBooleanFlag(String(raw || "").trim(), "--allow-goal-overlap") + let input = overlap.value + const nextDefaults = { ...defaults } + let scheduleMode = defaults.scheduleMode + let scheduleSyntax = "legacy" + + const first = firstToken(input) + const keyword = first.token.toLowerCase() + + if (keyword === "idle") { + nextDefaults.intervalMs = 0 + nextDefaults.immediate = true + scheduleMode = "idle" + scheduleSyntax = "idle" + input = first.rest + } else if (keyword === "every" || keyword === "after" || keyword === "in") { + const duration = firstToken(first.rest) + const intervalMs = parseDuration(duration.token) + if (intervalMs === null) { + return { + ok: false, + error: `Invalid ${keyword} schedule. Example: /loop ${keyword === "every" ? "every" : "after"} 5m continue the project`, + } + } + nextDefaults.intervalMs = intervalMs + nextDefaults.immediate = false + input = duration.rest + if (keyword === "every") { + scheduleMode = intervalMs === 0 ? "idle" : "interval" + scheduleSyntax = "every" + } else { + nextDefaults.maxRuns = 1 + scheduleMode = "once" + scheduleSyntax = "after" + } + } else { + const duration = parseDuration(first.token) + if (duration !== null) { + scheduleMode = duration === 0 ? "idle" : "interval" + } else if (nextDefaults.intervalMs === undefined || nextDefaults.intervalMs === null) { + // Plain /loop text is the ergonomic auto-continue form. Flags at the front + // are also accepted because the lower-level parser now receives an idle default. + nextDefaults.intervalMs = 0 + nextDefaults.immediate = nextDefaults.immediate ?? true + scheduleMode = "idle" + scheduleSyntax = "idle-shorthand" + } else { + scheduleMode = scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns) + } + } + + return { + ok: true, + args: input, + defaults: nextDefaults, + scheduleMode: scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns), + scheduleSyntax, + allowGoalOverlap: overlap.found || defaults.allowGoalOverlap === true, + } +} diff --git a/src/source/opencode/loop-commands.js b/src/source/opencode/loop-commands.js index 04d29860..ce4034e7 100644 --- a/src/source/opencode/loop-commands.js +++ b/src/source/opencode/loop-commands.js @@ -1,9 +1,11 @@ import { promises as fs } from "node:fs" import path from "node:path" -import { now as defaultNow, durationToText } from "../core/args.js" +import { now as defaultNow } from "../core/args.js" import { jobLabel, matchJob, isGoalJob, goalStatusText } from "../core/jobs.js" import { stateDir, pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState, removeState as defaultRemoveState } from "../core/state.js" import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js" +import { dedicatedGoalSummary, findDedicatedGoalForSession as defaultFindDedicatedGoalForSession } from "../runtime/companion-goal.js" +import { describeJobScheduling, listPersistedLoopSessions as defaultListPersistedLoopSessions } from "../runtime/loop-diagnostics.js" const SERVICE = "opencode-loop" const DEFAULT_PROGRESS_MD = `# Progress @@ -54,6 +56,8 @@ export function createLoopCommandHandlers(options = {}) { const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs.readFile(...args) const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs.writeFile(...args) + const listPersistedLoopSessions = typeof options.listPersistedLoopSessions === "function" ? options.listPersistedLoopSessions : defaultListPersistedLoopSessions + const findDedicatedGoalForSession = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : defaultFindDedicatedGoalForSession const runtimeVersion = options.runtimeVersion || process.version const runtimePlatform = options.runtimePlatform || process.platform @@ -88,21 +92,11 @@ export function createLoopCommandHandlers(options = {}) { async function statusLoop(directory, client, sessionID) { const state = await readState(directory, sessionID) const jobs = state.jobs || [] + const current = now() const lines = jobs.length ? jobs.map((job, index) => { - const current = now() - const intervalMs = Number(job.intervalMs || 0) - const lastRunAt = Number(job.lastRunAt || 0) - const createdAt = Date.parse(job.createdAt || "") - const dueAt = Number(job.runNowRequestedAt || 0) > 0 - ? current - : lastRunAt > 0 - ? lastRunAt + intervalMs - : job.immediate === false && Number.isFinite(createdAt) - ? createdAt + intervalMs - : current - const dueIn = Math.max(0, dueAt - current) + const scheduling = describeJobScheduling(job, current) 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(",") - return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}` + 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}` }) : ["No active loop jobs."] await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning") await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n")) @@ -117,18 +111,20 @@ export function createLoopCommandHandlers(options = {}) { async function helpLoop(client, sessionID) { await say(client, sessionID, [ "OpenCode Loop help:", - "/loop 0s Claude Code style auto-continue", - "/loop 5m --ask-never --safe interval autonomous prompt loop", - "/loop-command 200m /compact OpenCode slash-command loop, waits for idle", - "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt", - "/loop-shell 10m npm test shell loop, waits for idle", - "/loop-goal finish the feature and keep tests green experimental persistent goal mode", - "/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it", - "/loop-goal status | pause | resume | clear manage experimental goals", - "/loop 200m --command /compact same as command loop", - "/loop 0s --verify \"npm test\" verify after each assistant turn", - "/loop 0s --prompt-file loop-prompt.md load prompt from a file", - "/loop 0s --max-runtime 6h --max-failures 3 stop safely after limits", + "/loop continue the project auto-continue forever whenever the session becomes idle", + "/loop idle continue the project explicit form of the same idle loop", + "/loop every 5m continue the project recurring timer; first run after 5m, always waits for idle", + "/loop after 5m continue the project one-shot delayed prompt; runs once when 5m has passed and session is idle", + "/loop 5m continue the project legacy compact form: starts on next idle, then every 5m", + "/loop 5m --no-now continue the project legacy recurring form with first run delayed 5m", + "/loop-command 200m /compact OpenCode slash-command loop, waits for idle", + "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt", + "/loop-shell 10m npm test shell loop, waits for idle", + "/loop-goal finish the feature and keep tests green experimental persistent goal mode", + "/loop 0s --verify \"npm test\" verify after each assistant turn", + "/loop --prompt-file loop-prompt.md idle loop loading its prompt from a file", + "/loop 0s --max-runtime 6h --max-failures 3 stop safely after limits", + "Prompt-producing /loop jobs are blocked while dedicated /goal owns the same session; use another session or --allow-goal-overlap only intentionally.", "/loop-doctor | /loop-init | /loop-export", ].join("\n")) } @@ -154,17 +150,30 @@ export function createLoopCommandHandlers(options = {}) { async function doctorLoop(directory, client, sessionID) { const state = await readState(directory, sessionID) - await say(client, sessionID, [ + const persisted = await listPersistedLoopSessions(directory, sessionID) + const otherSessions = persisted.filter((entry) => !entry.current && entry.enabled > 0) + const dedicatedGoal = await findDedicatedGoalForSession(directory, sessionID) + const lines = [ "OpenCode Loop doctor:", `- plugin: ${SERVICE}`, `- project directory: ${directory}`, `- state directory: ${stateDir(directory)}`, - `- active jobs: ${(state.jobs || []).length}`, + `- current session: ${sessionID}`, + `- current-session jobs: ${(state.jobs || []).length}`, + `- dedicated /goal: ${dedicatedGoalSummary(dedicatedGoal)}`, + `- other persisted sessions with enabled jobs: ${otherSessions.length}`, `- node: ${runtimeVersion}`, `- platform: ${runtimePlatform}`, - "- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md", + "- smoke test: /loop --max-runs 1 --dry-run continue from progress.md", + "- delayed smoke test: /loop after 5m --dry-run continue from progress.md", + "- recurring smoke test: /loop every 5m --dry-run continue from progress.md", "- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it", - ].join("\n")) + ] + for (const entry of otherSessions.slice(0, 8)) { + lines.push(`- other session ${entry.sessionID}: jobs=${entry.jobs}, enabled=${entry.enabled}, never-ran=${entry.neverRan}`) + } + if (otherSessions.length > 8) lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`) + await say(client, sessionID, lines.join("\n")) } async function initLoop(directory, client, sessionID, args) { diff --git a/src/source/opencode/loop-registration.js b/src/source/opencode/loop-registration.js index 125d860b..50e052d5 100644 --- a/src/source/opencode/loop-registration.js +++ b/src/source/opencode/loop-registration.js @@ -1,7 +1,9 @@ import { parseLoopArgs as defaultParseLoopArgs } from "../core/args.js" -import { jobLabel, isGoalJob } from "../core/jobs.js" +import { actionKind, jobLabel, isGoalJob } from "../core/jobs.js" +import { normalizeLoopScheduleArgs as defaultNormalizeLoopScheduleArgs } from "../core/schedule-syntax.js" import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js" import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js" +import { dedicatedGoalOwnsContinuation, findDedicatedGoalForSession as defaultFindDedicatedGoalForSession } from "../runtime/companion-goal.js" import { normalizedModelRef as defaultNormalizedModelRef, getSessionExecutionContext as defaultGetSessionExecutionContext } from "./session-context.js" const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000 @@ -32,17 +34,31 @@ export function createLoopRegistration(options = {}) { const toast = requireFunction(options.toast, "toast") const say = requireFunction(options.say, "say") const parseLoopArgs = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : defaultParseLoopArgs + const normalizeLoopScheduleArgs = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : defaultNormalizeLoopScheduleArgs const readState = typeof options.readState === "function" ? options.readState : defaultReadState const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog const normalizedModelRef = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : defaultNormalizedModelRef const getSessionExecutionContext = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : defaultGetSessionExecutionContext + const findDedicatedGoalForSession = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : defaultFindDedicatedGoalForSession const configuredGuard = Number(options.defaultActiveGuardMs) const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS async function addLoop(directory, client, sessionID, args, defaults = {}) { - const parsed = parseLoopArgs(args, defaults) + const normalized = normalizeLoopScheduleArgs(args, defaults) + if (!normalized.ok) { await toast(client, normalized.error, "warning"); return } + + const parsed = parseLoopArgs(normalized.args, normalized.defaults) if (!parsed.ok) { await toast(client, parsed.error, "warning"); return } + parsed.job.scheduleMode = normalized.scheduleMode + parsed.job.scheduleSyntax = normalized.scheduleSyntax + parsed.job.allowGoalOverlap = normalized.allowGoalOverlap === true + if (normalized.scheduleSyntax === "after") { + parsed.job.immediate = false + parsed.job.maxRuns = 1 + parsed.job.lastRunAt = Date.parse(parsed.job.createdAt || "") || Date.now() + } + const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" } parsed.job.agent = defaults.agent || executionContext.agent || "build" parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model @@ -63,6 +79,21 @@ export function createLoopRegistration(options = {}) { ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS : Math.max(defaultActiveGuardMs, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000)) } + + const promptProducing = actionKind(parsed.job.action, parsed.job) === "prompt" + if (promptProducing && !parsed.job.allowGoalOverlap) { + const dedicatedGoal = await findDedicatedGoalForSession(directory, sessionID) + if (dedicatedGoalOwnsContinuation(dedicatedGoal)) { + await appendLoopLog(directory, "goal-overlap-blocked", { + sessionID, + job: parsed.job.name || parsed.job.id, + goal: dedicatedGoal.id, + }) + 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") + return + } + } + 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```") @@ -93,7 +124,13 @@ export function createLoopRegistration(options = {}) { await scheduleDueWork(directory, client, sessionID) if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID) await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success") - await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) }) + await appendLoopLog(directory, replaced ? "replace" : "add", { + sessionID, + job: parsed.job.name || parsed.job.id, + label: jobLabel(parsed.job), + scheduleMode: parsed.job.scheduleMode, + scheduleSyntax: parsed.job.scheduleSyntax, + }) } return { addLoop } diff --git a/src/source/runtime/companion-goal.js b/src/source/runtime/companion-goal.js new file mode 100644 index 00000000..81207301 --- /dev/null +++ b/src/source/runtime/companion-goal.js @@ -0,0 +1,37 @@ +import { promises as fs } from "node:fs" +import path from "node:path" + +function goalRoot(directory) { + return path.join(directory, ".opencode", "goals") +} + +export async function findDedicatedGoalForSession(directory, sessionID) { + if (!directory || !sessionID) return undefined + let names + try { + names = await fs.readdir(goalRoot(directory)) + } catch (error) { + if (error?.code === "ENOENT") return undefined + return undefined + } + + for (const name of names) { + if (!name.endsWith(".json")) continue + try { + const value = JSON.parse(await fs.readFile(path.join(goalRoot(directory), name), "utf8")) + if (value?.sessionID === sessionID) return value + } catch {} + } + return undefined +} + +export function dedicatedGoalOwnsContinuation(goal) { + return goal?.status === "active" +} + +export function dedicatedGoalSummary(goal) { + if (!goal) return "not detected" + const id = String(goal.id || "unknown").slice(0, 12) + const status = String(goal.status || "unknown") + return `${status} (${id})` +} diff --git a/src/source/runtime/loop-diagnostics.js b/src/source/runtime/loop-diagnostics.js new file mode 100644 index 00000000..6b37c142 --- /dev/null +++ b/src/source/runtime/loop-diagnostics.js @@ -0,0 +1,45 @@ +import { promises as fs } from "node:fs" +import path from "node:path" +import { stateDir } from "../core/state.js" +import { scheduleDescription, scheduleState } from "./schedule-policy.js" + +export function describeJobScheduling(job, current = Date.now()) { + return { + schedule: scheduleDescription(job), + state: scheduleState(job, current), + } +} + +export async function listPersistedLoopSessions(directory, currentSessionID) { + const root = stateDir(directory) + let names + try { + names = await fs.readdir(root) + } catch (error) { + if (error?.code === "ENOENT") return [] + return [] + } + + const sessions = [] + for (const name of names) { + if (!name.endsWith(".json")) continue + const sessionID = name.slice(0, -5) + try { + const parsed = JSON.parse(await fs.readFile(path.join(root, name), "utf8")) + const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [] + const enabled = jobs.filter((job) => job?.enabled !== false && !job?.paused).length + const neverRan = jobs.filter((job) => Number(job?.runCount || 0) === 0).length + sessions.push({ + sessionID, + current: sessionID === currentSessionID, + jobs: jobs.length, + enabled, + neverRan, + }) + } catch { + sessions.push({ sessionID, current: sessionID === currentSessionID, jobs: 0, enabled: 0, neverRan: 0, corrupt: true }) + } + } + + return sessions.sort((a, b) => Number(b.current) - Number(a.current) || b.enabled - a.enabled || a.sessionID.localeCompare(b.sessionID)) +} diff --git a/src/source/runtime/run-admission.js b/src/source/runtime/run-admission.js index 84f61ff4..89786653 100644 --- a/src/source/runtime/run-admission.js +++ b/src/source/runtime/run-admission.js @@ -1,10 +1,10 @@ import path from "node:path" import { now as defaultNow } from "../core/args.js" -import { isGoalJob } from "../core/jobs.js" import { pathExists as defaultPathExists, writeState as defaultWriteState } from "../core/state.js" import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js" import { toast as defaultToast } from "../opencode/host.js" import { dangerousShell as defaultDangerousShell } from "./job-workspace.js" +import { dueJobs as sharedDueJobs } from "./schedule-policy.js" function requireFunction(value, label) { if (typeof value !== "function") throw new TypeError(`createRunAdmissionRuntime requires ${label}`) @@ -25,18 +25,7 @@ export function createRunAdmissionRuntime(options = {}) { const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell function dueJobs(state, force = false) { - const current = now() - const due = (state.jobs || []).filter((job) => { - if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false - if (!job.enabled || job.paused) return false - if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false - if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true - if (Number(job.runNowRequestedAt || 0) > 0) return true - if (force) return true - if (job.watchPaths?.length) return job.watchTriggered === true - return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs - }) - return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0)) + return sharedDueJobs(state, now(), force) } async function reschedule(directory, client, sessionID) { diff --git a/src/source/runtime/schedule-policy.js b/src/source/runtime/schedule-policy.js new file mode 100644 index 00000000..8cfda17b --- /dev/null +++ b/src/source/runtime/schedule-policy.js @@ -0,0 +1,80 @@ +import { durationToText } from "../core/args.js" +import { isGoalJob } from "../core/jobs.js" + +const TERMINAL_GOAL_STATUSES = new Set(["completed", "blocked", "cleared"]) + +export function inferredScheduleMode(job) { + const explicit = String(job?.scheduleMode || "").toLowerCase() + if (["idle", "interval", "once", "watch"].includes(explicit)) return explicit + if (job?.watchPaths?.length) return "watch" + if (Number(job?.maxRuns || 0) === 1 && job?.immediate === false && Number(job?.intervalMs || 0) > 0) return "once" + return Number(job?.intervalMs || 0) === 0 ? "idle" : "interval" +} + +export function jobRunnable(job) { + if (!job) return false + if (isGoalJob(job) && TERMINAL_GOAL_STATUSES.has(job.goalStatus)) return false + if (!job.enabled || job.paused) return false + if (Number(job.maxRuns || 0) > 0 && Number(job.runCount || 0) >= Number(job.maxRuns || 0)) return false + return true +} + +export function jobDueAt(job, current = Date.now()) { + if (!jobRunnable(job)) return Infinity + if (Number(job.runNowRequestedAt || 0) > 0) return current + + const created = Date.parse(job.createdAt || "") + if (Number(job.maxRuntimeMs || 0) > 0 && Number.isFinite(created) && current - created >= Number(job.maxRuntimeMs || 0)) return current + + if (job.watchPaths?.length) return job.watchTriggered === true ? current : Infinity + + const intervalMs = Number(job.intervalMs || 0) + if (intervalMs === 0) return current + + const lastRunAt = Number(job.lastRunAt || 0) + if (!lastRunAt) { + if (job.immediate === false) return (Number.isFinite(created) ? created : current) + intervalMs + return current + } + return lastRunAt + intervalMs +} + +export function jobIsDue(job, current = Date.now(), force = false) { + if (!jobRunnable(job)) return false + if (force) return true + return jobDueAt(job, current) <= current +} + +export function dueJobs(state, current = Date.now(), force = false) { + return (state?.jobs || []) + .filter((job) => jobIsDue(job, current, force)) + .sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0)) +} + +export function nextDueDelay(state, current = Date.now()) { + let soonest = Infinity + for (const job of state?.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current)) + if (!Number.isFinite(soonest)) return Infinity + return Math.max(0, soonest - current) +} + +export function scheduleDescription(job) { + const mode = inferredScheduleMode(job) + const intervalMs = Number(job?.intervalMs || 0) + if (mode === "idle") return "every idle" + if (mode === "watch") return `on watch: ${(job.watchPaths || []).join(", ")}` + if (mode === "once") return intervalMs > 0 ? `once after ${durationToText(intervalMs)}` : "once on next idle" + if (job?.immediate === false) return `every ${durationToText(intervalMs)}, first after ${durationToText(intervalMs)}` + return `every ${durationToText(intervalMs)}, starts on next idle` +} + +export function scheduleState(job, current = Date.now()) { + if (!job?.enabled) return "stopped" + if (job?.paused) return "paused" + if (Number(job?.runNowRequestedAt || 0) > 0) return "due now; waiting for idle" + const mode = inferredScheduleMode(job) + const dueAt = jobDueAt(job, current) + if (!Number.isFinite(dueAt)) return mode === "watch" ? "waiting for watched change" : "not scheduled" + if (dueAt <= current) return mode === "idle" ? "waiting for idle" : "due; waiting for idle" + return `due in ${durationToText(dueAt - current)}` +} diff --git a/src/source/runtime/scheduler-diagnostics.js b/src/source/runtime/scheduler-diagnostics.js new file mode 100644 index 00000000..224c03a0 --- /dev/null +++ b/src/source/runtime/scheduler-diagnostics.js @@ -0,0 +1,30 @@ +const DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30_000 + +export function createSchedulerDiagnostics(options = {}) { + const now = typeof options.now === "function" ? options.now : Date.now + const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {} + const configured = Number(options.throttleMs) + const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS + const lastLogged = new Map() + + async function logDeferral(directory, sessionID, reason, extra = {}) { + const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}` + const current = now() + const previous = Number(lastLogged.get(key) || 0) + if (previous > 0 && current - previous < throttleMs) return false + lastLogged.set(key, current) + await appendLoopLog(directory, "deferred", { + sessionID, + reason, + ...extra, + }) + return true + } + + function clearSession(sessionID) { + const prefix = `${sessionID || "unknown"}:` + for (const key of lastLogged.keys()) if (key.startsWith(prefix)) lastLogged.delete(key) + } + + return { logDeferral, clearSession } +} diff --git a/src/source/runtime/scheduler.js b/src/source/runtime/scheduler.js index 8df69df4..8d653145 100644 --- a/src/source/runtime/scheduler.js +++ b/src/source/runtime/scheduler.js @@ -1,6 +1,10 @@ import { now } from "../core/args.js" import { isGoalJob } from "../core/jobs.js" import { readState } from "../core/state.js" +import { createSchedulerDiagnostics } from "./scheduler-diagnostics.js" +import { jobDueAt, nextDueDelay } from "./schedule-policy.js" + +export { jobDueAt, nextDueDelay } const DEFAULT_IDLE_DEBOUNCE_MS = 1_200 const DEFAULT_BUSY_RETRY_MS = 5_000 @@ -9,29 +13,6 @@ const DEFAULT_MAX_DUE_TIMER_MS = 2_147_000_000 const DEFAULT_HEARTBEAT_MS = 2_500 const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000 -export function jobDueAt(job, current = now()) { - if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity - if (!job.enabled || job.paused) return Infinity - if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity - if (Number(job.runNowRequestedAt || 0) > 0) return current - if (job.watchPaths?.length) return Infinity - const created = Date.parse(job.createdAt || "") - if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs) return current - if (job.intervalMs === 0) return current - if (!job.lastRunAt) { - if (job.immediate === false) return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0) - return current - } - return job.lastRunAt + (job.intervalMs || 0) -} - -export function nextDueDelay(state, current = now()) { - let soonest = Infinity - for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current)) - if (!Number.isFinite(soonest)) return Infinity - return Math.max(0, soonest - current) -} - export function createSchedulerRuntime(options = {}) { const idleTimers = new Map() const dueTimers = new Map() @@ -55,6 +36,7 @@ export function createSchedulerRuntime(options = {}) { const errorMessage = (error) => options.errorMessage ? options.errorMessage(error) : (error instanceof Error ? error.message : String(error || "unknown error")) const appendLog = async (directory, event, extra) => { if (options.appendLoopLog) await options.appendLoopLog(directory, event, extra) } const toast = async (client, message, level) => { if (options.toast) await options.toast(client, message, level) } + const diagnostics = createSchedulerDiagnostics({ appendLoopLog: appendLog, now: clock, throttleMs: options.deferralLogThrottleMs }) function stopHeartbeatIfIdle() { if (!knownSessions.size && heartbeatTimer) { @@ -107,11 +89,13 @@ export function createSchedulerRuntime(options = {}) { Promise.resolve() .then(async () => { if (!await options.sessionIsIdle?.(client, sessionID, directory)) { + await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "idle", retryMs: busyRetryMs }) await scheduleDueWork(directory, client, sessionID, busyRetryMs) return } await options.finalizeActiveRun?.(directory, client, sessionID) if (!await options.sessionIsIdle?.(client, sessionID, directory)) { + await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "idle", retryMs: busyRetryMs }) await scheduleDueWork(directory, client, sessionID, busyRetryMs) return } @@ -164,11 +148,13 @@ export function createSchedulerRuntime(options = {}) { Promise.resolve() .then(async () => { if (!await options.sessionIsIdle?.(client, sessionID, directory)) { + await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "due", retryMs: busyRetryMs }) await scheduleDueWork(directory, client, sessionID, busyRetryMs) return } await options.finalizeActiveRun?.(directory, client, sessionID) if (!await options.sessionIsIdle?.(client, sessionID, directory)) { + await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "due", retryMs: busyRetryMs }) await scheduleDueWork(directory, client, sessionID, busyRetryMs) return } @@ -193,6 +179,7 @@ export function createSchedulerRuntime(options = {}) { cancelIdleWork(sessionID) cancelDueWork(sessionID) stopWatchdog(sessionID) + diagnostics.clearSession(sessionID) knownSessions.delete(sessionID) stopHeartbeatIfIdle() } diff --git a/src/source/runtime/session-status.js b/src/source/runtime/session-status.js index 4179cd7a..1c280406 100644 --- a/src/source/runtime/session-status.js +++ b/src/source/runtime/session-status.js @@ -130,6 +130,24 @@ export function createSessionStatusRuntime(options = {}) { return cached === "idle" && seenAt > (active.startedAt || 0) } + async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) { + if (activeRuns.has(sessionID)) return false + if (liveType !== "busy" && liveType !== "retry") return false + // Never override the first fresh busy observation. Waiting at least one cache + // window gives the host time to expose a new user/assistant turn if it is real. + if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false + const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 }) + if (completion !== "completed") return false + markSessionStatus(sessionID, "idle") + await appendLoopLog(directory, "status-message-idle-recovery", { + sessionID, + staleStatus: liveType, + statusSeenAt: seenAt, + staleForMs: Math.max(0, now() - seenAt), + }) + return true + } + async function sessionStatusType(client, sessionID, directory, options = {}) { // OpenCode can briefly report an idle session while a long-running tool or // subtask is still executing. Tool lifecycle hooks are the more specific @@ -152,10 +170,16 @@ export function createSessionStatusRuntime(options = {}) { const live = await readLiveSessionStatus(client, sessionID, directory) if (live?.type) { - // Some OpenCode 1.15.x TUI builds can leave session.status at busy after a - // plugin-injected turn until the next user command touches the session. - // When the only reason we still think the session is busy is our own stale - // active-run guard, recover instead of waiting for another manual command. + // Some OpenCode 1.15.x/1.18.x TUI builds can leave session.status at busy + // after a plugin command acknowledgement. If no Loop run exists yet, a + // completed assistant tail plus an already-stale busy observation is a + // stronger signal than the unchanged live status. A genuinely running turn + // has an unfinished assistant tail (or latest user message) and is never + // force-recovered by this path. + if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt)) return "idle" + + // When a Loop-owned turn exists, use its exact start boundary so an older + // completed assistant message can never finalize a newer active run. if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) { const active = activeRuns.get(sessionID) if (active) {