diff --git a/README.md b/README.md index 26b3b0c..4d354ec 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Claude Code's `/loop`. Each `/loop` task is bound to the session that created it — never leaks to other sessions. -> **Upgrading to 0.4.0?** Two behavior changes to know about: (1) since 0.3.0, tasks die with the opencode process by default (`ephemeralTasks: false` restores persistence) — the upgrade drops the pre-0.3.0 `tasks.json` once; (2) scheduling-like input that used to silently create an Adaptive task (cron syntax, bare intervals like `/loop 5m`, unknown flags) now returns an explicit error pointing at `/loop help`. - ## Features - **`/loop 5m `** — fixed interval (s/m/h/d supported) @@ -17,10 +15,10 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Cla - **`/loop 30s --once `** — one-shot: fires once, then auto-cancels - **`/loop help`** — full usage, flags, and examples in the terminal - **Claude Code-style flags** — `--cancel/--list/--status/--pause/--resume/--stop/--stop-all` map to the matching subcommand -- **Per-session scoping** — tasks are bound to a `sessionID`; other sessions never see or fire them -- **Subcommands** — `list | status | cancel | stop | pause | resume | stop-all` (session-scoped; add `--all` to cross sessions; bare `stop` cancels every task in scope) -- **Internal ticker** — 5s loop drives task firing (no longer depends on `session.idle` events) -- **Prompt fidelity** — flags (`--once`, `--all`, `--jitter=*`) are only recognized before the prompt begins; `--` forces the rest to be treated as prompt text verbatim, and whitespace/newlines are preserved +- **Per-session scoping** — tasks are bound to a `sessionID`; other sessions never see, fire, or manage them +- **Subcommands** — `list | status | cancel | stop | pause | resume | stop-all` (all scoped to the current session; bare `stop` cancels every task in the session) +- **Internal ticker** — 5s loop drives task firing +- **Prompt fidelity** — flags (`--once`, `--jitter=*`) are only recognized before the prompt begins; `--` forces the rest to be treated as prompt text verbatim, and whitespace/newlines are preserved - **Per-process instance coordination** — plugin instances inside one process (case-variant plugin paths, per-command `opencode run` instances) elect a single leader so tasks never double-fire; a second OpenCode process in the same project fires its own tasks independently, and merge-writes prevent task loss - **Inflight guard** — double-set at ticker and `fireTask` level prevents double-firing even if opencode hot-reloads the plugin - **Wall-clock scheduling** — fixed tasks anchor to fire start; model-turn duration never inflates the interval @@ -29,7 +27,7 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Cla - **Configurable Jitter** — deterministic Fixed-task offset, controllable per command, tool call, or programmatic default - **Auto-expire** — tasks idle for more than 7 days are removed on load (active tasks never expire) - **Max 50 concurrent tasks** -- **LLM-callable tools** — `loop_schedule`, `loop_status` (session-bound by default) +- **LLM-callable tools** — `loop_schedule`, `loop_status` (scoped to the calling session) - **Inline results, Claude Code style** — every `/loop` result (create, list, cancel, pause, resume, stop-all, failures) is presented by the model directly in the conversation, in the user's own language — task lists render as a markdown table. No dialogs, no toasts ## Requirements @@ -80,7 +78,7 @@ Server config (`~/.config/opencode/opencode.json`): "plugin": ["opencode-plugin-loop"], "command": { "loop": { - "description": "Run prompts on a schedule. Intervals: s/m/h/d. Subcommands: help | list | status | cancel | pause | resume | stop-all (add --all to cross sessions)", + "description": "Run prompts on a schedule. Intervals: s/m/h/d. Subcommands: help | list | status | cancel | pause | resume | stop-all (all scoped to the current session)", "template": "$ARGUMENTS", "agent": "build" } @@ -120,10 +118,10 @@ Re-run `npm run build` after editing `src/`, then restart OpenCode to load the r /loop 30s --once remind me to stretch # one-shot: fires once, then auto-cancels ``` -Fixed tasks use deterministic Jitter by default for backward compatibility. Add +Fixed tasks use deterministic Jitter by default. Add `--jitter=false` for an exact interval or `--jitter=true` to enable it explicitly. Flags are only recognized **before the prompt begins**: anything after the first -prompt word — including text that looks like `--once`, `--all`, or `--jitter=*` — +prompt word — including text that looks like `--once` or `--jitter=*` — is part of the prompt and is preserved verbatim (whitespace and newlines included). Use `--` to force everything after it to be treated as prompt text: @@ -148,7 +146,7 @@ The natural-language form runs the request immediately in the current model turn Adaptive-to-Fixed conversion defaults to `jitterEnabled: false`, so an explicit cadence remains exact. -The fallback is written before the prompt is injected. A successful `reschedule` therefore replaces the fallback and is not overwritten after the model finishes. The preferred `delayMs` is relative to tool-call time, avoiding epoch arithmetic. An in-range model delay is stored exactly without Jitter; only an out-of-range request is clamped to the task's configured minimum or maximum delay. Fixed and Maintenance rescheduling remains unchanged. The legacy absolute `nextDueAtMs` remains supported, but passing it together with `delayMs` returns an error without changing the task. +The fallback is written before the prompt is injected. A successful `reschedule` therefore replaces the fallback and is not overwritten after the model finishes. The preferred `delayMs` is relative to tool-call time, avoiding epoch arithmetic. An in-range model delay is stored exactly without Jitter; only an out-of-range request is clamped to the task's configured minimum or maximum delay. Fixed and Maintenance rescheduling remains unchanged. An absolute `nextDueAtMs` is also accepted, but passing it together with `delayMs` returns an error without changing the task. ### Bare `/loop` — custom default prompt Create `.opencode/loop.md` (project) or `/.opencode/loop.md` (user) with your maintenance instructions: @@ -160,25 +158,21 @@ address each one. If everything is green, say so in one line. ### Subcommands -All subcommands are **session-scoped by default**. Add `--all` to operate across all sessions. +All subcommands are **scoped to the current session** — tasks created in other sessions are invisible to them, exactly like Claude Code's per-session `/loop` jobs. ``` /loop help # full usage, flags, and examples /loop list # show tasks in current session -/loop list --all # show all sessions (with [s:xxxx] tags) /loop status # alias for list /loop cancel # cancel one task in current session -/loop cancel --all # override scope /loop stop # alias for cancel /loop stop # cancel ALL tasks in current session -/loop stop --all # cancel ALL tasks across sessions /loop pause # pause one /loop resume # resume one (re-arms per mode) /loop stop-all # cancel all tasks in current session -/loop stop-all --all # cancel ALL tasks across sessions ``` -If you try `cancel ` for a task owned by another session, you'll get a refusal with a hint to add `--all`. The same strict scoping applies to `loop_schedule` and `loop_status` tools. +Trying to manage a task owned by another session reports "No task `` in this session" — switch to that session to manage it. The same strict scoping applies to the `loop_schedule` and `loop_status` tools. ### Migrating from Claude Code @@ -189,7 +183,7 @@ If you try `cancel ` for a task owned by another session, you'll get a refus | cancel/list via cron tools | `/loop cancel `, `/loop list` | | `--cancel`, `--list`, `--stop` | accepted — mapped to `cancel`, `list`, `stop` | | one-off reminder ("in 30m tell me X") | `/loop 30s --once ` | -| jobs die when the session ends | same default since 0.3.0 (`ephemeralTasks: false` opts out) | +| jobs die when the session ends | same default (`ephemeralTasks: false` opts out) | | cron expressions (`*/5 * * * *`) | not supported — use `5m` form (explicit error) | Two behavioral differences worth knowing: tasks only fire for the **currently active session** (switch sessions and the others wait; switch back and they catch up once), and fixed tasks fire on a 5-second ticker rather than exact wall-clock cron times (up to one ticker period late). @@ -207,7 +201,7 @@ No dialogs, no toasts — the conversation is the only output surface. ### Programmatic (LLM tools) -The plugin registers two LLM-callable tools. Both are session-bound by default; pass `all: true` to cross. +The plugin registers two LLM-callable tools. Both are scoped to the calling session. ```typescript loop_schedule({ @@ -220,7 +214,7 @@ loop_schedule({ loop_schedule({ action: "cancel", taskId: "abc12345", - // refuses if taskId belongs to another session (pass all: true to override) + // only works for tasks created in the calling session }) loop_schedule({ @@ -236,8 +230,7 @@ loop_schedule({ jitterEnabled: false, // default for Adaptive-to-Fixed conversion }) -loop_status({}) // current session only -loop_status({ all: true }) // all sessions +loop_status({}) // current session ``` ## Configuration @@ -262,13 +255,10 @@ task records its owning process (`ownerPid` + start time) in `tasks.json`. On load, tasks whose owner process is confirmed dead — e.g. after that OpenCode process exits — are dropped, so loop tasks never outlive the process that created them (the same lifecycle as Claude Code's `/loop`). Tasks owned by -other **live** OpenCode processes in the same project are kept: they remain -visible and manageable via `--all`, and each process fires only its own tasks. -Same-process plugin reloads keep their tasks. Pass `{ ephemeralTasks: false }` -in the plugin options to restore the previous behavior of persisting tasks -across process restarts. Note that upgrading from a release without per-task -owner tracking drops tasks that carry no owner identity once, since their -writer cannot be verified. +other **live** OpenCode processes in the same project are left untouched, and +each process fires only its own tasks. Same-process plugin reloads keep their +tasks. Set `ephemeralTasks: false` in the plugin options to persist tasks +across process restarts. Adaptive minimum and maximum delays are persisted on each task. The random fallback and any model-requested `reschedule` are both constrained by that task's bounds. Jitter @@ -277,8 +267,8 @@ is not added to a model-selected Adaptive time. For programmatic composition, `LoopConfig.defaultJitterEnabled` controls newly created Fixed tasks and defaults to `true`. An explicit command `--jitter=true|false` or tool argument `jitterEnabled` overrides that default. -Existing persisted Fixed tasks without a `jitterEnabled` field retain the legacy -Jitter-on behavior. Because the ticker checks every 5 seconds, actual prompt +Persisted Fixed tasks without a `jitterEnabled` field are treated as +Jitter-on. Because the ticker checks every 5 seconds, actual prompt injection can occur up to one ticker period after an exact due time. ## Per-session architecture @@ -292,7 +282,7 @@ Each `/loop` task carries a `sessionID` field: | User runs `/loop` in session B | Session B becomes active; A's task waits | | `session.deleted` for session A | All A's tasks cancelled automatically | | Plugin reload (`opencode` hot-reload) | Old tickers stop, new ticker starts; in-flight tasks guarded by `inflight` Set | -| Process restart (new pid) | With `ephemeralTasks` enabled (default), tasks whose owner process is dead are dropped on load, while tasks owned by other live processes are kept; with it disabled, tasks resume as before | +| Process restart (new pid) | With `ephemeralTasks` enabled (default), tasks whose owner process is dead are dropped on load, while tasks owned by other live processes are left untouched; with it disabled, tasks resume as before | | Old `tasks.json` without `sessionID` | Dropped on load (with log message) | ## Storage @@ -303,15 +293,15 @@ Tasks persist to `.opencode/cache/loop/tasks.json` (per project). Fire history i ### Package entrypoints -Current releases expose separate `opencode-plugin-loop/server` and `opencode-plugin-loop/tui` entrypoints so OpenCode installs that auto-load both keep working (the TUI entrypoint is a no-op since results are presented inline). The root export remains the v1-compatible server module for backward compatibility. Programmatic consumers should use the named factory: +The package exposes separate `opencode-plugin-loop/server` and `opencode-plugin-loop/tui` entrypoints so OpenCode installs that auto-load both keep working (the TUI entrypoint is a no-op since results are presented inline). The root export is the same server module. Programmatic consumers should use the named factory: ```typescript import { LoopPlugin } from "opencode-plugin-loop" ``` -### Task lines overlap the input area +### Results presentation -Older releases wrote `/loop` results directly to the terminal or into native dialogs/toasts. Upgrade to the current release: every result is presented inline by the model, and runtime diagnostics go to OpenCode's structured application log. +Every `/loop` result is presented inline by the model — nothing is written directly to the terminal, and no dialogs or toasts are used. Runtime diagnostics go to OpenCode's structured application log. Also make sure the plugin is installed from only one source. OpenCode loads npm plugins from `opencode.json` and copied plugins under `~/.config/opencode/plugins/` independently, even when they have the same package name. diff --git a/commands/loop.md b/commands/loop.md index e63d31a..3e15a25 100644 --- a/commands/loop.md +++ b/commands/loop.md @@ -1,6 +1,6 @@ --- -description: Run prompts on a schedule. Natural-language Adaptive requests run immediately and the model decides the next check; explicit intervals support --jitter=true|false and --once. Subcommands add --all to cross sessions. See /loop help. -argument-hint: "[5m] [--jitter=true|false] [--once] [prompt text... | help | list | cancel | pause | resume | stop-all] [--all]" +description: Run prompts on a schedule. Natural-language Adaptive requests run immediately and the model decides the next check; explicit intervals support --jitter=true|false and --once. Tasks are scoped to the current session. See /loop help. +argument-hint: "[5m] [--jitter=true|false] [--once] [prompt text... | help | list | cancel | pause | resume | stop-all]" agent: build --- diff --git a/package.json b/package.json index 50fad5e..17b6a48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-loop", - "version": "0.7.3", + "version": "0.7.4", "description": "/loop command for opencode — run prompts on a schedule (fixed, adaptive, or maintenance), modeled after Claude Code's /loop", "type": "module", "main": "./dist/index.js", diff --git a/src/index.ts b/src/index.ts index 1a0ec76..a31e732 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,12 +8,12 @@ * /loop check the deploy status — adaptive interval (1min–1hr) * /loop — bare: read .opencode/loop.md or default maintenance * - * Subcommands (all session-scoped; add `--all` to cross sessions): - * /loop list | status [--all] — show tasks - * /loop cancel | stop [--all] — cancel one - * /loop pause [--all] — pause one - * /loop resume [--all] — resume one - * /loop stop-all [--all] — cancel all + * Subcommands (all scoped to the current session): + * /loop list | status — show tasks + * /loop cancel | stop — cancel one + * /loop pause — pause one + * /loop resume — resume one + * /loop stop | stop-all — cancel all tasks in this session * * Per-session architecture: * - chat.message hook tracks the current active sessionID diff --git a/src/scheduler.ts b/src/scheduler.ts index 677b193..6d9d937 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -1,10 +1,10 @@ /** * Scheduler: orchestrates loop command parsing, task firing, and adaptive rescheduling. * - * Per-session scoping: + * Per-session scoping (matching Claude Code's /loop): * - Every task is bound to a sessionID at creation time - * - Strict: cancel/pause/resume only operate on tasks in the current session - * - --all flag: bypass session filter for global operations + * - list/cancel/pause/resume/stop only operate on tasks in the current session + * - Tasks from other sessions are invisible to management commands * * Implementation note: factory pattern (no `this` reliance) so opencode's * plugin loader can call us with or without `new`. @@ -47,10 +47,10 @@ interface SchedulerInstance { inflight: Set setCurrentSession(sessionID: string | null): void handleUserCommand(args: string, directory: string, sessionID?: string | null): Promise - handleCancel(id: string, allFlag: boolean): CommandParseResult | Promise - handlePause(id: string, allFlag: boolean): CommandParseResult | Promise - handleResume(id: string, allFlag: boolean): Promise - formatTaskList(tasks: LoopTask[], showSession?: boolean): string + handleCancel(id: string): CommandParseResult | Promise + handlePause(id: string): CommandParseResult | Promise + handleResume(id: string): Promise + formatTaskList(tasks: LoopTask[]): string loadDefaultPrompt(directory: string): string getDueTasks(now?: number): Promise getDueTasksForSession(sessionID: string, now?: number): Promise @@ -71,9 +71,9 @@ export type { SchedulerInstance } * Scanning stops at the first non-flag token or at a `--` terminator; the * remainder is returned as the original substring (whitespace and newlines * preserved), so prompt text that merely looks like a flag (`--once`, - * `--all`, `--jitter=*`) is never stripped from the prompt body. + * `--jitter=*`) is never stripped from the prompt body. */ -const PREFIX_FLAGS = new Set(["--once", "--all", "--jitter=true", "--jitter=false"]) +const PREFIX_FLAGS = new Set(["--once", "--jitter=true", "--jitter=false"]) function parseFlagPrefix(text: string): { once: boolean @@ -94,7 +94,7 @@ function parseFlagPrefix(text: string): { return { once, jitterEnabled, prompt: rest.trim() } } if (token === "--once") once = true - else if (token !== "--all") jitterEnabled = token === "--jitter=true" + else jitterEnabled = token === "--jitter=true" rest = rest.slice(m[0].length) } } @@ -124,7 +124,7 @@ const CC_FLAG_MAP: Record = { } /** Flags that are meaningful in command position (not errors when leading). */ -const LEADING_OK = new Set(["--all", "--jitter=true", "--jitter=false", "--once", "--"]) +const LEADING_OK = new Set(["--jitter=true", "--jitter=false", "--once", "--"]) /** crude cron-expression detector (five-field crontab syntax) (B9). */ function looksLikeCron(tokens: string[]): boolean { @@ -140,17 +140,16 @@ Usage: /loop Maintenance mode (uses .opencode/loop.md when present) /loop help Show this help -Subcommands (session-scoped; add --all to cross sessions): - list | status [--all] Show loop tasks - cancel [--all] Cancel one task - stop [] [--all] Cancel one task, or every task in scope when no id is given - pause [--all] Pause one task - resume [--all] Resume one task - stop-all [--all] Cancel all tasks +Subcommands (tasks are bound to the session that created them): + list | status Show this session's loop tasks + cancel Cancel one task + stop [] Cancel one task, or every task in this session when no id is given + pause Pause one task + resume Resume one task + stop-all Cancel all tasks in this session Flags (recognized only before the prompt begins; use -- to force the rest to be treated as prompt text verbatim): - --all Operate across all sessions --jitter=true|false Force Jitter on/off for a fixed task --once Fire once, then auto-cancel (fixed tasks only) @@ -180,7 +179,6 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta if (sessionID !== undefined) inst.currentSessionID = sessionID const trimmed = stripOuterQuotes(args.trim()) const tokens = trimmed === "" ? [] : trimmed.split(/\s+/) - const allFlag = tokens.includes("--all") let head = tokens[0]?.toLowerCase() // Claude Code-style leading flags map to subcommands (P-1). @@ -195,48 +193,31 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta } } - // Leading --all is sugar: `/loop --all list` == `/loop list --all`. - if (head === "--all") { - tokens.shift() - head = tokens[0]?.toLowerCase() - } - if (head === "cancel" || head === "stop") { - const id = tokens[1] === "--all" ? tokens[2] : tokens[1] + const id = tokens[1] if (!id) { - if (head === "cancel") return { message: "❌ Usage: /loop cancel [--all]" } - // Bare `/loop stop`: cancel every task in scope (session unless --all), - // matching Claude Code's stop semantics for the current context. - if (allFlag) { - const n = await inst.opts.store.cancelAll() - return { message: `🛑 Cancelled ${n} task(s) across all sessions` } - } + if (head === "cancel") return { message: "❌ Usage: /loop cancel " } + // Bare `/loop stop`: cancel every task in this session. const removed = await inst.opts.store.cancelBySession(inst.currentSessionID ?? "") return { message: `🛑 Cancelled ${removed} task(s) in current session` } } - return inst.handleCancel(id, allFlag) + return inst.handleCancel(id) } if (head === "list" || head === "status") { - const tasks = allFlag - ? inst.opts.store.list() - : inst.opts.store.listBySession(inst.currentSessionID ?? "") - return { message: inst.formatTaskList(tasks, allFlag) } + const tasks = inst.opts.store.listBySession(inst.currentSessionID ?? "") + return { message: inst.formatTaskList(tasks) } } if (head === "pause") { const id = tokens[1] - if (!id) return { message: "❌ Usage: /loop pause [--all]" } - return inst.handlePause(id, allFlag) + if (!id) return { message: "❌ Usage: /loop pause " } + return inst.handlePause(id) } if (head === "resume") { const id = tokens[1] - if (!id) return { message: "❌ Usage: /loop resume [--all]" } - return inst.handleResume(id, allFlag) + if (!id) return { message: "❌ Usage: /loop resume " } + return inst.handleResume(id) } if (head === "stop-all") { - if (allFlag) { - const n = await inst.opts.store.cancelAll() - return { message: `🛑 Cancelled ${n} task(s) across all sessions` } - } const removed = await inst.opts.store.cancelBySession(inst.currentSessionID ?? "") return { message: `🛑 Cancelled ${removed} task(s) in current session` } } @@ -342,39 +323,30 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta return { message: "❌ Empty loop command — see `/loop help`." } }, - handleCancel(id, allFlag) { + handleCancel(id) { const task = inst.opts.store.get(id) - if (!task) return { message: `❌ No task ${id}` } - if (!allFlag && task.sessionID !== inst.currentSessionID) { - return { - message: `❌ Task ${id} belongs to another session (${task.sessionID.slice(0, 8)}). Add \`--all\` to override.`, - } + if (!task || task.sessionID !== inst.currentSessionID) { + return { message: `❌ No task ${id} in this session` } } return inst.opts.store.cancel(id).then((r) => ({ - message: r ? `🛑 Cancelled ${id}` : `❌ No task ${id}`, + message: r ? `🛑 Cancelled ${id}` : `❌ No task ${id} in this session`, })) }, - handlePause(id, allFlag) { + handlePause(id) { const task = inst.opts.store.get(id) - if (!task) return { message: `❌ No task ${id}` } - if (!allFlag && task.sessionID !== inst.currentSessionID) { - return { - message: `❌ Task ${id} belongs to another session. Add \`--all\` to override.`, - } + if (!task || task.sessionID !== inst.currentSessionID) { + return { message: `❌ No task ${id} in this session` } } return inst.opts.store.setPaused(id, true).then((r) => ({ - message: r ? `⏸ Paused ${id}` : `❌ No task ${id}`, + message: r ? `⏸ Paused ${id}` : `❌ No task ${id} in this session`, })) }, - async handleResume(id, allFlag) { + async handleResume(id) { const task = inst.opts.store.get(id) - if (!task) return { message: `❌ No task ${id}` } - if (!allFlag && task.sessionID !== inst.currentSessionID) { - return { - message: `❌ Task ${id} belongs to another session. Add \`--all\` to override.`, - } + if (!task || task.sessionID !== inst.currentSessionID) { + return { message: `❌ No task ${id} in this session` } } const r = await inst.opts.store.setPaused(id, false) if (r) { @@ -388,10 +360,10 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta await inst.opts.store.reschedule(r.id, Date.now() + r.adaptiveMaxMs) } } - return { message: r ? `▶ Resumed ${id}` : `❌ No task ${id}` } + return { message: r ? `▶ Resumed ${id}` : `❌ No task ${id} in this session` } }, - formatTaskList(tasks, showSession = false) { + formatTaskList(tasks) { if (tasks.length === 0) return "📭 No loop tasks. Use `/loop ` or `/loop 5m ` to create one." const lines = [`📋 ${tasks.length} loop task(s):`] @@ -404,13 +376,11 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta : `maintenance ${(t.adaptiveMaxMs ?? 0) / 1000}s` const status = t.paused ? "⏸ paused" : "▶ active" const onceTag = t.once ? " • once" : "" - const sessionTag = - showSession && t.sessionID ? ` [s:${t.sessionID.slice(0, 8)}]` : "" const preview = t.prompt.length > 60 ? t.prompt.slice(0, 60) + "..." : t.prompt - lines.push(` [${t.id}]${sessionTag} ${status} • ${interval}${onceTag} • ${preview}`) + lines.push(` [${t.id}] ${status} • ${interval}${onceTag} • ${preview}`) } lines.push( - `Manage: \`/loop cancel|pause|resume \` (add \`--all\` to cross sessions) or \`/loop stop-all\`` + `Manage: \`/loop cancel|pause|resume \` or \`/loop stop-all\`` ) return lines.join("\n") }, diff --git a/src/store.ts b/src/store.ts index a4d80b3..7778ca3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -27,9 +27,10 @@ export interface LoopStoreOptions { * Ephemeral lifecycle (default true): tasks die with their owning process. * Each task records ownerPid + ownerStartedAt; on load, tasks whose owner * process is confirmed dead are dropped, while tasks owned by other LIVE - * processes are kept (visible/manageable via --all). Tasks without owner - * fields (written before owner leases existed) fall back to the file-writer - * identity rule (pid + start time). + * processes are kept in the shared file (they are fired by their owner; + * this instance never touches them). Tasks without owner fields (written + * before owner leases existed) fall back to the file-writer identity rule + * (pid + start time). */ ephemeralTasks?: boolean /** Injectable process identity for tests; defaults to the current process. */ @@ -64,7 +65,6 @@ interface LoopStoreInstance { create(input: CreateTaskInput): Promise cancel(id: string): Promise cancelByPromptPrefix(prefix: string): Promise - cancelAll(): Promise cancelBySession(sessionID: string): Promise list(): LoopTask[] listBySession(sessionID: string): LoopTask[] @@ -228,7 +228,10 @@ export function LoopStore(this: unknown, options?: LoopStoreOptions): LoopStoreI }, persist: async () => { // Merge with the on-disk state instead of blindly overwriting it, so - // concurrent instances do not lose each other's tasks (B1). + // concurrent instances do not lose each other's tasks (B1). Merge + // direction matters: only ids this instance actually touched (dirtyIds) + // may overwrite the disk copy; an untouched in-memory task is by + // definition staler than the disk version and must yield to it. const disk = readDisk() if (disk) { const diskIds = new Set(disk.tasks.map((t) => t.id)) @@ -238,12 +241,17 @@ export function LoopStore(this: unknown, options?: LoopStoreOptions): LoopStoreI byId.set(dt.id, dt) } for (const t of inst.state.tasks) { - if (!dirtyIds.has(t.id) && !diskIds.has(t.id)) { + if (dirtyIds.has(t.id)) { + byId.set(t.id, t) + continue + } + if (!diskIds.has(t.id)) { // Vanished from disk and untouched by us: another instance // cancelled it — accept the deletion. continue } - byId.set(t.id, t) + // Untouched by us and present on disk: keep the disk version + // (already in byId) — it may carry another process's updates. } inst.state.tasks = Array.from(byId.values()) } @@ -314,18 +322,6 @@ export function LoopStore(this: unknown, options?: LoopStoreOptions): LoopStoreI if (!task) return null return inst.cancel(task.id) }, - cancelAll: async () => { - for (const t of inst.state.tasks) tombstones.add(t.id) - // Also tombstone ids only known to the disk copy (created by other - // instances) so stop-all --all really empties the shared file. - const disk = readDisk() - if (disk) for (const t of disk.tasks) tombstones.add(t.id) - const n = inst.state.tasks.length - inst.state.tasks = [] - dirtyIds.clear() - await inst.persist() - return n - }, cancelBySession: async (sessionID) => { if (!sessionID) return 0 const removed = inst.state.tasks.filter((t) => t.sessionID === sessionID) diff --git a/src/tools/loop-tools.ts b/src/tools/loop-tools.ts index cad01b9..752fb67 100644 --- a/src/tools/loop-tools.ts +++ b/src/tools/loop-tools.ts @@ -5,10 +5,10 @@ * - loop_schedule: create/list/cancel/reschedule/set_fixed/pause/resume * - loop_status: show running tasks + recent fire history * - * Per-session scoping: + * Per-session scoping (matching Claude Code's /loop): * - create binds task to ctx.sessionID (ToolContext) - * - list/status default to current session, with `all: true` to see all - * - cancel/pause/resume/reschedule/set_fixed are session-scoped unless `all: true` + * - list/status show only the current session's tasks + * - cancel/pause/resume/reschedule/set_fixed only affect the current session's tasks */ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" @@ -24,7 +24,7 @@ export async function buildLoopTools( return { loop_schedule: tool({ description: - "Manage /loop tasks: create, list, cancel, pause, resume, reschedule, or convert an Adaptive task to Fixed. Each task is bound to the session that created it; pass all=true to cross session boundaries.", + "Manage /loop tasks: create, list, cancel, pause, resume, reschedule, or convert an Adaptive task to Fixed. Each task is bound to the session that created it; management actions only affect the current session's tasks.", args: { action: z.enum(["create", "list", "cancel", "reschedule", "set_fixed", "pause", "resume"]), taskId: z.string().optional().describe("Required for cancel/reschedule/set_fixed/pause/resume"), @@ -38,21 +38,23 @@ export async function buildLoopTools( .enum(["fixed", "adaptive", "maintenance"]) .optional() .describe("Default: fixed if intervalMs set, else adaptive"), - all: z - .boolean() - .optional() - .describe("For list/cancel/reschedule/set_fixed/pause/resume: ignore session scope (cross-session)"), }, async execute(args, ctx) { const directory = ctx.directory || process.cwd() const currentSID = ctx.sessionID + // Session-scoped lookup: tasks owned by other sessions are treated as + // nonexistent, matching Claude Code's per-session /loop jobs. + const getOwn = (id: string) => { + const t = store.get(id) + return t && t.sessionID === currentSID ? t : null + } switch (args.action) { case "list": { - const tasks = args.all ? store.list() : store.listBySession(currentSID) + const tasks = store.listBySession(currentSID) return JSON.stringify( { ok: true, - scope: args.all ? "all" : currentSID, + scope: currentSID, count: tasks.length, tasks: tasks.map((t) => ({ id: t.id, @@ -76,20 +78,14 @@ export async function buildLoopTools( case "cancel": { if (!args.taskId) return JSON.stringify({ ok: false, error: "taskId required" }) - const t = store.get(args.taskId) - if (!t) return JSON.stringify({ ok: false, error: `No task ${args.taskId}` }) - if (!args.all && t.sessionID !== currentSID) { - return JSON.stringify({ - ok: false, - error: `Task belongs to another session (${t.sessionID}). Pass all=true to override.`, - }) - } + if (!getOwn(args.taskId)) + return JSON.stringify({ ok: false, error: `No task ${args.taskId} in this session` }) const removed = await store.cancel(args.taskId) return JSON.stringify( { ok: !!removed, removed, - message: removed ? `Cancelled ${args.taskId}` : `No task ${args.taskId}`, + message: removed ? `Cancelled ${args.taskId}` : `No task ${args.taskId} in this session`, }, null, 2 @@ -99,14 +95,8 @@ export async function buildLoopTools( case "pause": { if (!args.taskId) return JSON.stringify({ ok: false, error: "taskId required" }) - const t = store.get(args.taskId) - if (!t) return JSON.stringify({ ok: false, error: `No task ${args.taskId}` }) - if (!args.all && t.sessionID !== currentSID) { - return JSON.stringify({ - ok: false, - error: `Task belongs to another session. Pass all=true to override.`, - }) - } + if (!getOwn(args.taskId)) + return JSON.stringify({ ok: false, error: `No task ${args.taskId} in this session` }) const r = await store.setPaused(args.taskId, true) return JSON.stringify({ ok: !!r, task: r }, null, 2) } @@ -114,14 +104,8 @@ export async function buildLoopTools( case "resume": { if (!args.taskId) return JSON.stringify({ ok: false, error: "taskId required" }) - const t = store.get(args.taskId) - if (!t) return JSON.stringify({ ok: false, error: `No task ${args.taskId}` }) - if (!args.all && t.sessionID !== currentSID) { - return JSON.stringify({ - ok: false, - error: `Task belongs to another session. Pass all=true to override.`, - }) - } + if (!getOwn(args.taskId)) + return JSON.stringify({ ok: false, error: `No task ${args.taskId} in this session` }) const r = await store.setPaused(args.taskId, false) if (r) { // Re-arm per mode (B6), same as /loop resume. @@ -205,14 +189,9 @@ export async function buildLoopTools( } if (!args.taskId) return JSON.stringify({ ok: false, error: "taskId required" }) - const t = store.get(args.taskId) - if (!t) return JSON.stringify({ ok: false, error: `No task ${args.taskId}` }) - if (!args.all && t.sessionID !== currentSID) { - return JSON.stringify({ - ok: false, - error: `Task belongs to another session. Pass all=true to override.`, - }) - } + const t = getOwn(args.taskId) + if (!t) + return JSON.stringify({ ok: false, error: `No task ${args.taskId} in this session` }) if (args.delayMs !== undefined && t.mode !== "adaptive") { return JSON.stringify({ ok: false, @@ -257,14 +236,9 @@ export async function buildLoopTools( if (!v.ok) { return JSON.stringify({ ok: false, error: v.error }) } - const t = store.get(args.taskId) - if (!t) return JSON.stringify({ ok: false, error: `No task ${args.taskId}` }) - if (!args.all && t.sessionID !== currentSID) { - return JSON.stringify({ - ok: false, - error: `Task belongs to another session. Pass all=true to override.`, - }) - } + const t = getOwn(args.taskId) + if (!t) + return JSON.stringify({ ok: false, error: `No task ${args.taskId} in this session` }) if (t.mode !== "adaptive") { return JSON.stringify({ ok: false, @@ -308,15 +282,13 @@ export async function buildLoopTools( loop_status: tool({ description: - "Show /loop task status. Defaults to current session; pass all=true to see all sessions.", - args: { - all: z.boolean().optional().describe("Show tasks from all sessions"), - }, - async execute(args, ctx) { - const tasks = args.all ? store.list() : store.listBySession(ctx.sessionID) + "Show /loop task status for the current session.", + args: {}, + async execute(_args, ctx) { + const tasks = store.listBySession(ctx.sessionID) const summary = { ok: true, - scope: args.all ? "all" : ctx.sessionID, + scope: ctx.sessionID, activeTasks: tasks.filter((t) => !t.paused).length, pausedTasks: tasks.filter((t) => t.paused).length, tasks: tasks.map((t) => ({ diff --git a/src/types.ts b/src/types.ts index 3aca625..70082de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,8 +62,8 @@ export interface LoopConfig { * Ephemeral lifecycle (default true, matching Claude Code's /loop): tasks die * with the opencode process that created them. Each task records its owner * process; on load, tasks whose owner is confirmed dead are dropped, while - * tasks owned by other LIVE processes are kept (visible via --all). Set to - * false to keep tasks across process restarts. + * tasks owned by other LIVE processes are left untouched (each process fires + * only its own tasks). Set to false to keep tasks across process restarts. */ ephemeralTasks?: boolean /** diff --git a/tests/package-exports.test.mjs b/tests/package-exports.test.mjs index f80d973..5e97b13 100644 --- a/tests/package-exports.test.mjs +++ b/tests/package-exports.test.mjs @@ -6,8 +6,8 @@ const packageJson = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ) -test("publishes the 0.7.3 release", () => { - assert.equal(packageJson.version, "0.7.3") +test("publishes the 0.7.4 release", () => { + assert.equal(packageJson.version, "0.7.4") }) test("publishes explicit server and TUI plugin entrypoints", () => { diff --git a/tests/per-session.test.mjs b/tests/per-session.test.mjs index eee37ae..3d4e62d 100644 --- a/tests/per-session.test.mjs +++ b/tests/per-session.test.mjs @@ -233,41 +233,41 @@ test("scheduler list is session-scoped by default", async () => { } }) -test("scheduler list --all shows all sessions with [s:] tags", async () => { +test("scheduler list --all lists only the current session (no --all anymore)", async () => { const { sched, dir } = makeScheduler() try { await sched.handleUserCommand("5m a1", "/tmp", SID_A) await sched.handleUserCommand("5m b1", "/tmp", SID_B) sched.currentSessionID = SID_A const r = await sched.handleUserCommand("list --all", "/tmp", SID_A) - assert.match(r.message, /2 loop task/) - assert.match(r.message, /\[s:/, "session tags present") + assert.match(r.message, /1 loop task/) + assert.ok(!r.message.includes("[s:"), "no session tags") } finally { rmSync(dir, { recursive: true }) } }) -test("scheduler cancel refuses cross-session without --all", async () => { +test("scheduler cancel of another session's task is refused as not-in-session", async () => { const { sched, store, dir } = makeScheduler() try { const b = await sched.handleUserCommand("5m in B", "/tmp", SID_B) sched.currentSessionID = SID_A const r = await sched.handleUserCommand(`cancel ${b.task.id}`, "/tmp", SID_A) - assert.match(r.message, /another session/) + assert.match(r.message, /No task .+ in this session/) assert.ok(store.get(b.task.id), "task preserved") } finally { rmSync(dir, { recursive: true }) } }) -test("scheduler cancel --all overrides", async () => { +test("scheduler cancel --all no longer overrides cross-session", async () => { const { sched, store, dir } = makeScheduler() try { const b = await sched.handleUserCommand("5m in B", "/tmp", SID_B) sched.currentSessionID = SID_A const r = await sched.handleUserCommand(`cancel ${b.task.id} --all`, "/tmp", SID_A) - assert.match(r.message, /Cancelled/) - assert.equal(store.get(b.task.id), null) + assert.match(r.message, /No task .+ in this session/) + assert.ok(store.get(b.task.id), "task preserved") } finally { rmSync(dir, { recursive: true }) } @@ -289,14 +289,15 @@ test("scheduler stop-all defaults to current session only", async () => { } }) -test("scheduler stop-all --all cancels everything", async () => { +test("scheduler stop-all --all still only affects the current session", async () => { const { sched, store, dir } = makeScheduler() try { await sched.handleUserCommand("5m a", "/tmp", SID_A) await sched.handleUserCommand("5m b", "/tmp", SID_B) const r = await sched.handleUserCommand("stop-all --all", "/tmp", SID_A) - assert.match(r.message, /across all sessions/) - assert.equal(store.list().length, 0) + assert.match(r.message, /current session/) + assert.equal(store.listBySession(SID_A).length, 0) + assert.equal(store.listBySession(SID_B).length, 1, "B intact") } finally { rmSync(dir, { recursive: true }) } @@ -830,12 +831,13 @@ test("loop_schedule set_fixed is session-scoped unless all is true", async () => ) ) assert.equal(rejected.ok, false) + assert.match(rejected.error, /in this session/) assert.equal(store.get(task.id).mode, "adaptive") const allowed = JSON.parse( await tools.loop_schedule.execute( - { action: "set_fixed", taskId: task.id, intervalMs: 120_000, all: true }, - mockCtx(SID_A, dir) + { action: "set_fixed", taskId: task.id, intervalMs: 120_000 }, + mockCtx(SID_B, dir) ) ) assert.equal(allowed.ok, true) @@ -869,7 +871,7 @@ test("loop_schedule keeps fixed reschedule times unrestricted", async () => { } }) -test("loop_schedule list defaults to current session", async () => { +test("loop_schedule list only ever shows the current session", async () => { const { store, sched, dir } = makeScheduler() try { const tools = await buildLoopTools(store, sched) @@ -878,15 +880,16 @@ test("loop_schedule list defaults to current session", async () => { const r = JSON.parse(await tools.loop_schedule.execute({ action: "list" }, mockCtx(SID_A, dir))) assert.equal(r.count, 1) assert.equal(r.scope, SID_A) + // `all` is gone from the schema; passing it changes nothing. const rAll = JSON.parse(await tools.loop_schedule.execute({ action: "list", all: true }, mockCtx(SID_A, dir))) - assert.equal(rAll.count, 2) - assert.equal(rAll.scope, "all") + assert.equal(rAll.count, 1) + assert.equal(rAll.scope, SID_A) } finally { rmSync(dir, { recursive: true }) } }) -test("loop_schedule cancel refuses cross-session without all", async () => { +test("loop_schedule cancel is refused for another session's task", async () => { const { store, sched, dir } = makeScheduler() try { const tools = await buildLoopTools(store, sched) @@ -903,20 +906,21 @@ test("loop_schedule cancel refuses cross-session without all", async () => { ) ) assert.equal(r2.ok, false) - assert.match(r2.error, /another session/) + assert.match(r2.error, /in this session/) const r3 = JSON.parse( await tools.loop_schedule.execute( { action: "cancel", taskId: r1.task.id, all: true }, mockCtx(SID_A, dir) ) ) - assert.equal(r3.ok, true) + assert.equal(r3.ok, false, "all=true no longer overrides") + assert.match(r3.error, /in this session/) } finally { rmSync(dir, { recursive: true }) } }) -test("loop_status defaults to current session", async () => { +test("loop_status only ever summarizes the current session", async () => { const { store, sched, dir } = makeScheduler() try { const tools = await buildLoopTools(store, sched) @@ -926,8 +930,8 @@ test("loop_status defaults to current session", async () => { assert.equal(r.scope, SID_A) assert.equal(r.activeTasks, 1) const rAll = JSON.parse(await tools.loop_status.execute({ all: true }, mockCtx(SID_A, dir))) - assert.equal(rAll.scope, "all") - assert.equal(rAll.activeTasks, 2) + assert.equal(rAll.scope, SID_A) + assert.equal(rAll.activeTasks, 1) } finally { rmSync(dir, { recursive: true }) } @@ -1080,7 +1084,7 @@ test("plugin: tool layer enforces session scope", async () => { assert.equal(r2.count, 0) const r3 = JSON.parse(await tool.execute({ action: "list", all: true }, mockCtx(SID_A, dir))) - assert.equal(r3.count, 1) + assert.equal(r3.count, 0, "all=true no longer widens scope") const r4 = JSON.parse(await tool.execute({ action: "cancel", taskId: taskInB }, mockCtx(SID_A, dir))) assert.equal(r4.ok, false) diff --git a/tests/scheduler.test.mjs b/tests/scheduler.test.mjs index 99ddfd7..58b4c51 100644 --- a/tests/scheduler.test.mjs +++ b/tests/scheduler.test.mjs @@ -251,55 +251,56 @@ test("/loop list defaults to current session", async () => { sched.currentSessionID = "sA" const r = await sched.handleUserCommand("list", "/tmp", "sA") assert.match(r.message, /1 loop task/, "A sees 1 task") - assert.ok(!r.message.includes("sB"), "A does not see B's session tag (--all not used)") + assert.ok(!r.message.includes("in B"), "A does not see B's task") } finally { rmSync(dir, { recursive: true }) } }) -test("/loop list --all shows all sessions with tags", async () => { +test("/loop list --all is just list: --all is not a flag anymore", async () => { const { sched, dir } = makeScheduler() try { await sched.handleUserCommand("5m in A", "/tmp", "sA") await sched.handleUserCommand("5m in B", "/tmp", "sB") sched.currentSessionID = "sA" const r = await sched.handleUserCommand("list --all", "/tmp", "sA") - assert.match(r.message, /2 loop task/, "all sees 2") - assert.match(r.message, /\[s:/, "shows session tags") + assert.match(r.message, /1 loop task/, "only the current session is listed") + assert.ok(!r.message.includes("[s:"), "no session tags") } finally { rmSync(dir, { recursive: true }) } }) -test("/loop cancel refuses cross-session without --all", async () => { +test("/loop cancel of another session's task is refused as not-in-session", async () => { const { sched, store, dir } = makeScheduler() try { - const a = await sched.handleUserCommand("5m in A", "/tmp", "sA") + await sched.handleUserCommand("5m in A", "/tmp", "sA") const b = await sched.handleUserCommand("5m in B", "/tmp", "sB") sched.currentSessionID = "sA" const r = await sched.handleUserCommand(`cancel ${b.task.id}`, "/tmp", "sA") - assert.match(r.message, /another session/) + assert.match(r.message, /No task .+ in this session/) + assert.ok(!r.message.includes("--all"), "no --all hint remains") assert.ok(store.get(b.task.id), "B task still exists") } finally { rmSync(dir, { recursive: true }) } }) -test("/loop cancel --all overrides cross-session", async () => { +test("/loop cancel --all no longer overrides cross-session", async () => { const { sched, store, dir } = makeScheduler() try { await sched.handleUserCommand("5m in A", "/tmp", "sA") const b = await sched.handleUserCommand("5m in B", "/tmp", "sB") sched.currentSessionID = "sA" const r = await sched.handleUserCommand(`cancel ${b.task.id} --all`, "/tmp", "sA") - assert.match(r.message, /Cancelled/) - assert.equal(store.get(b.task.id), null) + assert.match(r.message, /No task .+ in this session/) + assert.ok(store.get(b.task.id), "B task untouched") } finally { rmSync(dir, { recursive: true }) } }) -test("/loop stop-all defaults to current session only", async () => { +test("/loop stop-all cancels the current session only", async () => { const { sched, store, dir } = makeScheduler() try { await sched.handleUserCommand("5m in A1", "/tmp", "sA") @@ -315,40 +316,40 @@ test("/loop stop-all defaults to current session only", async () => { } }) -test("/loop stop-all --all cancels everything", async () => { +test("/loop stop-all --all still only affects the current session", async () => { const { sched, store, dir } = makeScheduler() try { await sched.handleUserCommand("5m in A", "/tmp", "sA") await sched.handleUserCommand("5m in B", "/tmp", "sB") sched.currentSessionID = "sA" const r = await sched.handleUserCommand("stop-all --all", "/tmp", "sA") - assert.match(r.message, /across all sessions/) - assert.equal(store.list().length, 0) + assert.match(r.message, /current session/) + assert.equal(store.listBySession("sA").length, 0) + assert.equal(store.listBySession("sB").length, 1, "B untouched") } finally { rmSync(dir, { recursive: true }) } }) -test("/loop pause refuses cross-session without --all", async () => { +test("/loop pause of another session's task is refused", async () => { const { sched, store, dir } = makeScheduler() try { const b = await sched.handleUserCommand("5m in B", "/tmp", "sB") sched.currentSessionID = "sA" const r = await sched.handleUserCommand(`pause ${b.task.id}`, "/tmp", "sA") - assert.match(r.message, /another session/) + assert.match(r.message, /No task .+ in this session/) assert.equal(store.get(b.task.id).paused, false) } finally { rmSync(dir, { recursive: true }) } }) -test("/loop resume --all works cross-session and rearms fixed", async () => { +test("/loop resume in the owning session rearms fixed", async () => { const { sched, store, dir } = makeScheduler() try { const b = await sched.handleUserCommand("5m in B", "/tmp", "sB") await store.setPaused(b.task.id, true) - sched.currentSessionID = "sA" - const r = await sched.handleUserCommand(`resume ${b.task.id} --all`, "/tmp", "sA") + const r = await sched.handleUserCommand(`resume ${b.task.id}`, "/tmp", "sB") assert.match(r.message, /Resumed/) assert.equal(store.get(b.task.id).paused, false) } finally { @@ -428,7 +429,7 @@ test("/loop help shows usage with all flags", async () => { try { const r = await sched.handleUserCommand("help", "/tmp", "s1") assert.equal(r.task, undefined) - assert.match(r.message, /--all/) + assert.ok(!r.message.includes("--all"), "--all is gone from help") assert.match(r.message, /--jitter=true\|false/) assert.match(r.message, /--once/) assert.match(r.message, /--cancel, --list/) @@ -476,13 +477,15 @@ test("cron-shaped input is rejected with guidance", async () => { } }) -test("--all is stripped from fixed and adaptive prompts (B2)", async () => { - const { sched, dir } = makeScheduler() +test("--all is no longer a flag: kept as prompt text / rejected in command position", async () => { + const { sched, store, dir } = makeScheduler() try { const r1 = await sched.handleUserCommand("5m --all check deploy", "/tmp", "s1") - assert.equal(r1.task.prompt, "check deploy") + assert.equal(r1.task.prompt, "--all check deploy", "not a known flag → treated as prompt text") const r2 = await sched.handleUserCommand("--all check the weather", "/tmp", "s1") - assert.equal(r2.task.prompt, "check the weather") + assert.equal(r2.task, undefined) + assert.match(r2.message, /Unknown flag "--all"/) + assert.equal(store.list().length, 1) } finally { rmSync(dir, { recursive: true }) } @@ -740,14 +743,14 @@ test("/loop stop still cancels a single task", async () => { } }) -test("/loop stop --all without an id cancels across sessions", async () => { +test("/loop stop --all: the extra token is treated as an id and not found", async () => { const { sched, store, dir } = makeScheduler() try { await sched.handleUserCommand("1m task A1", "/tmp", "sA") await sched.handleUserCommand("1m task B1", "/tmp", "sB") const r = await sched.handleUserCommand("stop --all", "/tmp", "sA") - assert.match(r.message, /Cancelled 2 task\(s\) across all sessions/) - assert.equal(store.list().length, 0) + assert.match(r.message, /No task --all in this session/) + assert.equal(store.list().length, 2, "nothing cancelled") } finally { rmSync(dir, { recursive: true }) } diff --git a/tests/store.test.mjs b/tests/store.test.mjs index 9eabe2a..6bc1a65 100644 --- a/tests/store.test.mjs +++ b/tests/store.test.mjs @@ -688,21 +688,31 @@ test("merge-write: tasks cancelled by another instance are accepted on next pers } }) -test("cancelAll also tombstones ids only present on disk", async () => { +test("merge-write: an untouched in-memory task never overwrites a newer disk version", async () => { const dir = mkdtempSync(join(tmpdir(), "loop-test-")) try { const id = { pid: 1, startedAt: Date.now() } const s1 = new LoopStore({ storageDir: dir, processIdentity: id }) - const s2 = new LoopStore({ storageDir: dir, processIdentity: id }) await s1.load() - await s1.create({ prompt: "mine", mode: "fixed", intervalMs: 60_000, directory: "/tmp", sessionID: "sA" }) + const t = await s1.create({ prompt: "shared", mode: "fixed", intervalMs: 60_000, directory: "/tmp", sessionID: "sA" }) + + // s2 loads the same task, pauses it, and persists — disk now newer. + const s2 = new LoopStore({ storageDir: dir, processIdentity: id }) await s2.load() - await s2.create({ prompt: "peer", mode: "fixed", intervalMs: 60_000, directory: "/tmp", sessionID: "sB" }) - // s1's memory only knows "mine"; stop-all must also kill s2's "peer". - await s1.cancelAll() - const s3 = new LoopStore({ storageDir: dir, processIdentity: id }) - await s3.load() - assert.equal(s3.list().length, 0) + await s2.setPaused(t.id, true) + + // s1 never touched the task (not dirty). Its persist of an UNRELATED + // change must not roll back s2's pause. + await s1.create({ prompt: "unrelated", mode: "fixed", intervalMs: 60_000, directory: "/tmp", sessionID: "sA" }) + const disk = JSON.parse(readFileSync(join(dir, "tasks.json"), "utf-8")) + const shared = disk.tasks.find((x) => x.id === t.id) + assert.equal(shared.paused, true, "s2's pause survives s1's persist") + assert.equal(s1.get(t.id).paused, true, "s1's memory converges to disk") + + // Dirty writes still win: s1 now pauses-then-resumes deliberately. + await s1.setPaused(t.id, false) + const disk2 = JSON.parse(readFileSync(join(dir, "tasks.json"), "utf-8")) + assert.equal(disk2.tasks.find((x) => x.id === t.id).paused, false, "dirty version wins") } finally { rmSync(dir, { recursive: true }) }