Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ The mechanism is deliberately generic: this plugin exports a capability, not a p

OpenCode prepares auxiliary requests with the same session identity as the agent loop: `compaction` (context summarization inside a turn), `generate` (stateless text generation), and `title` (automatic chat naming). Compaction and generate share the turn's fate: if their hold is interrupted, the primary recovery continuation re-runs the step and the auxiliary request is prepared again. An automatic title does not: OpenCode generates it once when the first input of an untitled root session is promoted, and a lost title request is not retried by OpenCode itself.

A held `title` request therefore persists a semantic `pending-title` recovery marker (same minimal shape as a deferral: session ID, provider/model, location, timestamp, origin) instead of the primary continuation. A session can hold a primary continuation and a pending-title marker at the same time. When the allowed window arrives the marker is delivered as a check, never as a replay or a mutation:
A held `compaction` request (manual `/compact` or automatic context compaction — both are the same `model.request` kind) persists a semantic `pending-compaction` marker. It is not a replay: as soon as the recovered session is settled and idle, the plugin admits OpenCode's own compaction again (`session.compact`), and the re-triggered request holds at the pre-model guard through the peak window exactly like the original. OpenCode's runner keeps a pending compaction ahead of queued prompts, so a compact-first workflow survives a lost hold with its order intact: compaction first, then the prompt that was waiting behind it. Earlier-started work is different: if a compaction is recovered alongside a primary continuation for the same session, both are admitted in the same pass — compaction first, continuation behind it — and the continuation is not treated as stale just because the compaction completed first.

A held `title` request therefore persists a semantic `pending-title` recovery marker (same minimal shape as a deferral: session ID, provider/model, location, timestamp, origin) instead of the primary continuation. A session can hold a primary continuation, a pending-title marker, and a pending-compaction marker at the same time; each class is superseded only by a fresh request of its own kind, and each transport-guard class (`title`, `compaction`, `primary`) clears only its own entry — a compact request can never clear a primary deferral or a pending title, and a `generate` request clears nothing. When the allowed window arrives the title marker is delivered as a check, never as a replay or a mutation:

- A session that already has a title — generated by OpenCode, generated by another process, or renamed by the user — clears the marker without any request, so a title is never overwritten and never generated twice.
- A session that still needs a title is left to OpenCode's own generation. Every path that loses a title hold (inactivity, supersede, shutdown, reload) also resumes the session — the plugin's same-session continuation or OpenCode's restart recovery — and that input promotion is OpenCode's native trigger for automatic naming. The marker waits for the title, clears once it appears, and is dropped with a diagnostic if no title appears within the settle cap; a later input promotion still names the session normally.
Expand Down Expand Up @@ -118,6 +120,8 @@ OpenCode's v2 recovery is at-least-once, not exactly-once. If the plugin finds a

A `WAITING_OFFPEAK` record is written before the guarded request is allowed. That state is safe for automatic same-session resume in `auto` mode. A hold for a live request (interactive or queue-owned) is intentionally not persisted as a request: it belongs to a running OpenCode session, so a plugin/server shutdown fails it closed instead of replaying it; the OpenCode server's own execution-claim recovery can resume sessions interrupted by a crash, and the plugin enforces pricing when they come back. Retry recoveries and inactivity-released holds are persisted (minimal metadata: session ID, provider/model, location) because their provider request is already finished or the session was deliberately interrupted; a reload or restart during peak keeps the pending continuation, which is delivered only when the session has settled with a failed/interrupted outcome and pricing allows. A running OpenCode server is required for automatic timers; the plugin does not install or operate a separate daemon. The supported OpenCode background service can remain running when the TUI terminal closes. Machine-restart startup is delegated to the host OS service manager if desired.

Recovery delivery is guarded against live work so it can never duplicate it. `session.outcome` only changes at settlement, so a running turn still reports its previous outcome; the engine tracks holds that are still parked in this instance and the execution state reported by OpenCode's own `session.execution.started` / terminal events, and it skips (rather than delivers) any recovery whose session is currently owned by a live hold or a running execution — ending the hold or settling the execution wakes the engine again. That closes the race where a boundary release and a recovery admission could otherwise both fire for one request. Delivery itself stays at-most-once per entry (the entry is removed before the request is sent) and per class: a compaction marker is delivered as the semantic compaction re-trigger, a primary marker as the continuation prompt, a title marker as a check.

## Override

`/offpeak run-now <task-id>` is a task-scoped, 15-minute override. It is persisted with an absolute expiry and shown in status while active. OFF pauses its use; it does not create or extend it. Expiry returns the next provider request to normal policy enforcement.
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.1.9 - 2026-10-16

- **A compaction held for peak pricing is no longer lost when its hold dies.** Manual compact-first is a normal workflow: compact the session, then send the next prompt. During PEAK that compaction waits at the pre-model hook like any other request — but its hold was memory-only, so a plugin reload, a server restart, or the inactivity sweep ended it with a terminal failed compaction and, worse, left the prompt queued behind it stranded: nothing resumed at off-peak and the compaction never ran. A held compaction now persists a semantic `pending-compaction` recovery entry (with `origin: "pending-compaction"`; the raw provider request is never replayed). As soon as the recovered session is settled and idle, the plugin admits OpenCode's own compaction again; the runner keeps a pending compaction ahead of queued prompts so compact-first order survives, and the re-triggered request holds at the pre-model guard through the peak window exactly like the original. A user cancel clears the marker; inactivity and supersede keep it.
- **Recovery classes are independently owned.** A session can carry a primary `deferred`, a `pending-title` and a `pending-compaction` entry at the same time. Each `model.request` hold supersedes only its own class, and each transport-guard class (`title`, `compaction`, `primary`) removes only its own entry — a compact request can no longer clear a primary deferral or a pending title, and a `generate` request clears nothing. At the allowed window the pending compaction is admitted first, then primary continuations, then title markers, so the native compact-first order is preserved. `/offpeak status` reports `compactionRecoveryWaiting`.
- **Recovery cannot race a live execution into a duplicate.** `session.outcome` only updates at settlement, so a still-running turn reports its previous outcome; the engine now tracks live holds and the execution state from OpenCode's own `session.execution.*` events, and recovery delivery waits while either owns the session. A boundary release and a recovery admission can no longer both fire for one request (previously reachable with a stale failed outcome), and every delivery is still at-most-once.
- Evidence: 115/115 tests (99 existing + 7 cancellation tests from v0.1.7/0.1.8 + new deterministic pending-compaction coverage: the exact compact-first-then-prompt workflow, reload and restart recovery, interrupt semantics, coexistence of all three recovery classes, no class clobbering, at-most-once delivery, ordering, and the live-hold/no-duplicate guard). Real OpenCode 2.0.30 runs with a local fixture provider: compact-first through a peak window completes exactly once with the prompt behind it and zero peak-priced traffic; the same workflow with a full server restart recovers the compaction, runs it before the prompt, and completes with clean recovery state (the same harness against v0.1.8 loses the compaction and duplicates the queued prompt's continuation).

## 0.1.8 - 2026-10-16

- **A session can hold several requests at once, and cancelling cancels all of them.** OpenCode prepares auxiliary requests (the automatic title, generate, compaction) concurrently with the agent loop, so one session can have more than one request parked. The held-request bookkeeping is now per request rather than per session: `cancelHold` cancels every request of that session that is still waiting, each gets its own one-shot refusal at the transport boundary, and none can be left behind to dispatch at the next allowed window. A refusal is matched by provider, model, and request kind, and only a fresh request *of the same kind* supersedes an un-consumed refusal.
Expand Down
4 changes: 4 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,7 @@ Another plugin can decide that a parked request is obsolete — the most importa
## 2026-10-13: Peak-aware retry recovery uses OpenCode's retry hook

A request that starts during OFF_PEAK can cross into PEAK and then fail with a retryable transport error (`Timed out waiting for response headers`). OpenCode's own retries would either reach the provider during peak or exhaust and fail the turn, and the guard's boundary hold cannot outlast the header-timeout clock, which starts at attempt start. The plugin now registers OpenCode's supported `retry` hook: during PEAK, with the plugin ON in `auto` mode and a retryable failure, it sets `decision = { retry: false }` and marks the work to continue at the next allowed window (a queue task moves to `WAITING_OFFPEAK`; an interactive session is recorded with minimal persisted recovery metadata scoped to the plugin location). The continuation uses the existing safe continuation prompt in the same session; the failed provider request is never replayed. Non-retryable failures stay `FAILED`, OFF/`queue`/`guard` modes and the override semantics are unchanged, and no new runtime, scheduler, database, or polling loop is added. Evidence: OpenCode 2.0.27 retry hook events (attempts 2–5; `provider.transport`/`provider.internal`/`provider.rate-limit` retryable, `provider.invalid-request` terminal at attempt 2) plus isolated real-binary end-to-end runs proving zero provider traffic during peak and automatic same-session continuation.

## 2026-10-16: A held compaction is recovered semantically, admitted as soon as the session is settled

Manual compact-first — compact the session, then send the next prompt — is a normal workflow, and during PEAK the compaction waits at the pre-model hook like any other request. That hold was memory-only, so a plugin reload, a full server restart, or the 60-minute inactivity sweep ended it as a terminal failed compaction and left the prompt queued behind it stranded; nothing resumed at the off-peak window. The plugin now persists a `pending-compaction` marker for a held compaction (manual and automatic compaction are the same `model.request` kind, so both are covered) and recovers it by admitting OpenCode's own compaction again — never by replaying the provider request. Three source facts shaped the design. First, `session.compact` is the supported semantic trigger, and OpenCode's runner keeps a pending compaction ahead of queued prompts (`packages/core/src/session/inbox.ts` reorders earlier steers behind a pending compaction "so their text stays verbatim after the checkpoint"), so the recovered compaction lands before the prompt that waited behind it. Second, the re-triggered request holds at the same pre-model guard, so recovery during peak still produces zero provider traffic. Third, because of that native priority there is no reason to wait for the allowed window to admit the recovery: it is delivered as soon as the session is settled and idle (any settled outcome, including `succeeded` — unlike a primary continuation, a requested compaction that never ran is not made stale by another completed turn), which is what restores compact-first order after a restart instead of letting a later prompt win the inbox. When a primary continuation coexists with a recovered compaction for one session, both are admitted in the same pass in native order (compaction, then continuation) so the continuation is not dropped by the compaction's own success. The v0.1.7/v0.1.8 transport guard cleared primary recoveries for any non-title kind, which could have let a compact request erase a primary deferral; each request kind now clears only its own recovery class. Evidence: deterministic tests for the exact workflow, reload/restart/inactivity/user-cancel semantics, three-class coexistence, class independence, at-most-once delivery and ordering, and real-CLI fixture runs where a restart during a held compaction still ends with exactly one completed compaction, the prompt behind it running once, and clean recovery state.
Loading
Loading