diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1c3119e..7731733 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,8 +17,10 @@ flowchart LR RPC --> Engine[Queue and toggle engine] Engine --> Storage[OpenCode plugin storage] Engine --> Sessions[OpenCode session API] + Sessions --> Defer[Pre-model hook: model.request] Sessions --> Guard[HTTP and WebSocket request hooks] - Guard --> Policy[Pricing policy evaluator] + Defer --> Policy[Pricing policy evaluator] + Guard --> Policy Policy -->|allowed| Provider[Configured model provider] Policy -->|peak, OFF, or unknown| Wait[Block or wait before send] ``` @@ -51,24 +53,29 @@ OFF does not delete state. It stops automatic starts and resumes. If a queued ta ## Request guard and policy evaluation -The server plugin attaches through `context.session.hook()` to OpenCode v2's `http.request`, `experimental.ws.handshake`, and `experimental.ws.send` hooks. These run before the provider transport sends the request/frame. A queued `auto` task transitions to `WAITING_OFFPEAK` and holds that same session at the request boundary until the plugin is enabled and the pricing policy allows it (or its explicit override is still valid). +The server plugin registers two lifecycle points: -In `auto` mode, a normal interactive request for a supported model is held at the same boundary instead of being discarded. It creates no task and no session, is tracked only in server memory (shown by `/offpeak status`), and is released exactly once when the plugin is enabled and the policy allows. Wake-ups come from the shared config-change signal plus one timer scheduled for the policy's `nextTransition`; there is no polling loop. If the plugin shuts down while a request is held, the request fails closed as blocked rather than being sent under an uncertain pricing state. Requests in `queue`/`guard` mode, and requests that arrive while the plugin is OFF, are blocked immediately; OFF does not release a request that is already held. +- `model.request` — runs while OpenCode prepares every model request (primary, compaction, title, generate), before the provider attempt and its response-header timeout exist. In `auto` mode this is where a peak-priced request is deferred: nothing is sent, no attempt timer runs, and the same session continues the same turn at the next allowed window. +- `http.request`, `experimental.ws.handshake`, and `experimental.ws.send` — run immediately before the provider transport sends the request/frame. These remain the final hard barrier: a blocked request never crosses them, and OFF/`queue`/`guard` modes block interactive requests here exactly as before. -### Peak-aware retry recovery +A queued `auto` task transitions to `WAITING_OFFPEAK` and waits at whichever point it reaches first (normally `model.request`); its existing override still applies. In `auto` mode, a normal interactive request for a supported model is deferred the same way. It creates no task and no session, is tracked only in server memory (shown by `/offpeak status`), and is released exactly once when the plugin is enabled and the policy allows. Wake-ups come from the shared config-change signal plus one timer scheduled for the policy's `nextTransition`; there is no polling loop. If the plugin shuts down while a request is held, the request fails closed as blocked rather than being sent under an uncertain pricing state. Requests in `queue`/`guard` mode, and requests that arrive while the plugin is OFF, are blocked immediately by the transport guard; OFF does not release a request that is already held. -OpenCode retries retryable transport failures on its own (for the agent loop: attempts 2–4 with jittered backoff, denied at attempt 5). Each retry attempt is a new paid request, and every attempt re-enters the `http.request`/`ws` guard hooks, so a retry during peak would either be held (and time out while held, because the header-timeout clock starts at attempt start) or exhaust the retry budget and fail the turn. +### Peak-aware retry recovery and the inactivity safety net + +OpenCode retries retryable transport failures on its own (for the agent loop: jittered exponential backoff; header-timeout failures are bounded to three retries). Each retry attempt is a new paid request, and every attempt re-enters the whole hook chain, so a retry during peak would either be held or exhaust the retry budget and fail the turn. The server plugin therefore also registers OpenCode's supported `retry` hook. The hook runs after OpenCode classifies a failure and before any retry is scheduled. When the plugin is ON in `auto` mode, the failure is retryable (`decision.retry === true`, or a retryable `provider.transport`/`provider.internal`/`provider.rate-limit` type after a retry sequence), and the policy blocks the model, the plugin sets `decision = { retry: false }` and records the recovery: - A queue-owned task in `RUNNING` moves to `WAITING_OFFPEAK` (not `FAILED`) and keeps its session ID. The existing pump resumes it at the next allowed window with the safe continuation prompt. - A normal interactive session is recorded in a small persisted recovery document scoped to the plugin location; at the next allowed window the engine sends the same safe continuation prompt into the same session. +OpenCode's location-activity sweep interrupts sessions whose location has had no durable events for 60 minutes (`session.execution.interrupted`, reason `inactivity`). The plugin subscribes to OpenCode's supported event stream and, for `inactivity` interrupts of sessions it is currently deferring, releases the hold and records the same persisted recovery so the session continues automatically at the next allowed window. `user` and `shutdown` interrupts are ignored by that path. + Non-retryable failures (`provider.invalid-request`, `provider.auth`, `provider.quota`, `provider.content-filter`, …) are left untouched and still end as `FAILED`/error. Failures while pricing is allowed keep OpenCode's normal retry behavior. OFF, `queue`, and `guard` modes are unchanged. The continuation is a new state-aware turn; the failed provider request is never replayed byte-for-byte. A recovery whose session never settles is dropped without sending anything. Policy matching uses provider IDs and exact/glob model IDs. A configured provider with an unknown model is `UNKNOWN` and blocked. Invalid timezone or schedule data is blocked. Providers without an installed policy are `UNTRACKED`; they are not covered by this plugin until a policy is added. Queue tasks must match a valid policy. -The built-in `deepseek-opencode-go` policy is defined once in `src/policy.ts`, in UTC. Time evaluation uses `Intl.DateTimeFormat` and the policy's IANA timezone, including weekday changes and DST. The user's display timezone does not change pricing boundaries. +The built-in `deepseek-opencode-go` policy is defined once in `src/policy.ts`, in UTC, with the Chinese statutory holiday calendar in `src/holidays.ts`. Time evaluation uses `Intl.DateTimeFormat` and the policy's IANA timezone, including weekday changes and DST; holiday dates are evaluated on the calendar's own timezone (`Asia/Shanghai`) so the host-local timezone never matters. The user's display timezone does not change pricing boundaries. Full-day off-peak exception dates override every weekday peak window, and `nextTransition` skips holiday peak windows. ## Queue execution and sessions @@ -80,7 +87,7 @@ Completion uses OpenCode's session outcome (`succeeded`) after the session wait OpenCode's v2 recovery is at-least-once, not exactly-once. If the plugin finds a task in `RUNNING` after plugin/server startup, it waits for its stored session. If OpenCode reports success, the task completes. Otherwise the outcome may be ambiguous, so the plugin marks it `FAILED` and does not send a duplicate prompt automatically. The user can inspect and explicitly retry the same session. -A `WAITING_OFFPEAK` record is written before the guarded request is allowed. That state is safe for automatic same-session resume in `auto` mode. Interactive holds are intentionally not persisted: they belong to a live provider request, so a server restart cannot release them and shutdown fails them closed instead. Retry recoveries for interactive sessions are persisted (minimal metadata: session ID, provider/model, location) because their provider request is already finished; 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. +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. ## Override diff --git a/CHANGELOG.md b/CHANGELOG.md index 61971ad..cdc5aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.1.4 - 2026-10-14 + +- Peak waiting moved before the provider attempt. A normal interactive request (and a queue-owned auto task) that reaches peak pricing is now deferred at OpenCode v2's `model.request` hook, which runs while the request is being prepared and before the provider attempt (and its response-header timeout) exists. +- This removes the `Timed out waiting for response headers` failure for requests submitted during peak: no provider attempt is open for hours, no retry is required as the waiting mechanism, and the same session continues the same turn at the next off-peak transition with no synthetic continuation prompt. +- The HTTP/WebSocket transport guard remains the final hard barrier: no provider request can cross a blocked pricing window, and `queue`/`guard`/OFF behavior is unchanged (handled exactly as before by the transport guard). +- Requests that start during off-peak and cross into peak keep the v0.1.3 retry-veto recovery (retryable failure during peak → persisted same-session continuation at the boundary). +- A held primary interactive request persists a deferral while it waits and removes it on normal release, so a plugin reload or server restart during peak still delivers exactly one same-session continuation once the allowed window arrives. +- New safety net: OpenCode's `session.execution.interrupted` events are handled with origin-aware semantics. An `inactivity` interrupt of a deferred session keeps the deferral and continues the same session at the next allowed window; a `user` interrupt clears a deferral and aborts the hold; `superseded`/`shutdown` abort the hold and let the session's own recovery own the outcome. +- The built-in `deepseek-opencode-go` policy now treats Chinese statutory public holidays as full-day OFF_PEAK. The policy model supports explicit full-day OFF_PEAK exception calendars (year, dates, source URL, verification date, description), evaluated on the calendar's own time zone and overriding every weekday peak window. The 2026 China calendar from the State Council notice ships in `src/holidays.ts`; weekend make-up workdays stay OFF_PEAK; years without a calendar keep the deterministic weekly schedule; `nextTransition` skips holiday peak windows. +- Added deterministic regression coverage for the pre-model deferral (release at the scheduled boundary, zero provider traffic), passivity outside `auto`/ON/peak, untracked/unknown handling, the queue-owned deferral, deferral persistence across reload, user/inactivity/shutdown interrupts, and the full 2026 holiday calendar (periods, make-up workdays, adjacent days, `nextTransition`, calendar time zone, unknown years, validation). + ## 0.1.3 - 2026-10-13 - Retryable provider/network failures that happen after peak pricing begins no longer lose the work. The plugin registers OpenCode v2's supported `retry` hook and vetoes retries while pricing is blocked (`decision = { retry: false }`), so no retry attempt reaches the provider during peak. diff --git a/DECISIONS.md b/DECISIONS.md index fc595d2..feb38a3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -60,6 +60,16 @@ The latest official OpenCode v2 release is 2.0.27; no 2.1 line exists. The `@ope A normal interactive OpenCode request for a supported model was thrown away at peak pricing even though the queue-owned `auto` path already waited for the next boundary. The guard now holds the pending interactive request at the transport boundary when the plugin is ON and mode is `auto`: no task record, no new session, no synthesized prompt, and no persistent state. The hold uses the existing shared signal plus one timer for the policy's `nextTransition`, is released exactly once when the plugin is enabled and the policy allows, and is shown as `interactiveWaiting` in `/offpeak status`. `queue` and `guard` modes keep blocking interactive requests immediately; OFF stays passive and never releases a held request; and shutdown fails held requests closed instead of releasing them under uncertain pricing. Tests use a manual clock to prove the release comes from the scheduled boundary itself, not from an external wake-up. +## 2026-10-14: Peak deferral happens at the pre-model hook, not the transport boundary + +OpenCode 2.0.27 begins an attempt's response-header timeout (default 300s) at attempt start, before the `http.request` hook chain completes; the v0.1.2–v0.1.3 interactive hold therefore could not outlast the timeout and surfaced `Timed out waiting for response headers` during peak. The deferral now runs at the supported `model.request` hook, which fires while the model request is prepared and before the provider attempt exists. In `auto` mode a peak request waits there and the same session continues the same turn at the boundary; the transport guard stays in place as the final hard barrier and `queue`/`guard`/OFF behavior is unchanged. The retry-hook recovery remains for requests that started off-peak and failed after crossing into peak. Because OpenCode's location-activity sweep can interrupt a session that has had no durable events for 60 minutes, the plugin also listens for `session.execution.interrupted` with reason `inactivity` and converts that specific interruption into the existing persisted recovery; user and shutdown interrupts are left untouched. No new runtime, scheduler, database, daemon, or polling loop is added. + +## 2026-10-14: Chinese public holidays are full-day OFF_PEAK in the built-in policy + +The DeepSeek pricing documentation defines the peak windows and the exemption: "Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full." The OpenCode Go pricing page documents the same weekly windows for this provider and links to the DeepSeek page for the definition, and this pass was explicitly instructed to implement the confirmed holiday rule as part of the canonical policy. The policy model therefore supports explicit full-day OFF_PEAK exception calendars (`holidays`) that override every weekday peak window, and the built-in policy carries the 2026 China calendar from the State Council notice (国办发明电〔2025〕7号, published 2025-11-04) in `src/holidays.ts` with year, dates, source URL, verification date, and description. Holiday dates are evaluated on the calendar's own time zone (`Asia/Shanghai`), independent of the host and the policy display time zone. Adjusted weekend make-up workdays stay OFF_PEAK because weekends are always off-peak. `nextTransition` scans up to 14 days so long holiday runs cannot hide the next real transition, and a year without a calendar keeps the deterministic weekly schedule rather than guessing. Annual maintenance is required: the State Council publishes the next year's arrangement each November. Upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) (open, no vendor reply) reports peak-rate billing for Go during the 2026 National Day holiday; the October 1–7 Go billing comparison is not exposed per request (the provider offers only aggregate usage percentages), so that report is the cue for the annual re-check rather than a blocker for this confirmed rule. + +A stored `policies` array that is an unmodified copy of the built-in policy (same id, providers, models, time zone, multiplier, and weekly windows) adopts the built-in holiday calendar when the config is read. Existing installations commonly persist such a copy after their first `/offpeak on|off`, so they gain yearly holiday updates without editing the file, while any customized policy — including an explicit `holidays` list — is preserved exactly. + ## 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. diff --git a/README.md b/README.md index 83881cd..eae427c 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ Queue OpenCode agent tasks, block paid model requests during expensive pricing windows, and resume the **same OpenCode session** when off-peak pricing returns. -`opencode-offpeak` is an OpenCode CLI v2 plugin. Its default policy covers DeepSeek models through OpenCode Go. The request guard runs at OpenCode's outbound model transport boundary; a task that reaches peak pricing pauses before its next provider request and keeps its session and project context. In `auto` mode the same hold applies to normal interactive messages: a supported request sent during peak pricing stays pending at the boundary and proceeds automatically when off-peak pricing returns, so you do not need to resend it. If a request is already in flight and fails with a retryable provider/network error after peak pricing begins, the retry is stopped before it can reach the provider and the same session continues automatically at the next off-peak window. +`opencode-offpeak` is an OpenCode CLI v2 plugin. Its default policy covers DeepSeek models through OpenCode Go. Pricing is enforced at two lifecycle points: a pre-model deferral and the outbound transport guard. In `auto` mode, a supported request that reaches peak pricing waits at the pre-model hook — before OpenCode creates the provider attempt and its response-header timer — and the same session continues the same turn automatically when off-peak pricing returns, with zero provider traffic while pricing is blocked. The transport guard remains the final hard barrier immediately before OpenCode sends an HTTP request or an experimental WebSocket frame. If a request is already in flight and fails with a retryable provider/network error after peak pricing begins, the retry is stopped before it can reach the provider and the same session continues automatically at the next off-peak window. > **Compatibility:** OpenCode CLI `>=2.0.26 <2.1.0`. This release targets the v2 plugin API shipped with OpenCode 2.0.26 and is validated against OpenCode 2.0.26 and 2.0.27 (the current official v2 release). The package declares the range in `engines.opencode` and both entrypoints refuse to initialize on an unsupported host with an actionable error instead of failing later at runtime. Later API versions are not claimed until CI and runtime checks cover them. ## Install ```sh -opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.3 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.4 ``` -This installs the released `v0.1.3` directly from its GitHub tag using [OpenCode v2's supported npm-compatible Git package syntax](https://opencode.ai/v2/docs/plugins). The release includes both server and TUI entrypoints. npm publication is optional future distribution; it is not required to install or use this release. +This installs the released `v0.1.4` directly from its GitHub tag using [OpenCode v2's supported npm-compatible Git package syntax](https://opencode.ai/v2/docs/plugins). The release includes both server and TUI entrypoints. npm publication is optional future distribution; it is not required to install or use this release. Restart OpenCode after installation. The plugin defaults to enabled, uses the current system timezone for display, and enforces the built-in DeepSeek/OpenCode Go policy without extra setup. @@ -53,13 +53,17 @@ Select the provider and model first. Queueing captures the current model and wor ## What happens at a pricing boundary -The guard checks the provider and model immediately before OpenCode sends an HTTP request or an experimental WebSocket frame. If a queued `auto` task is in a blocked window, the guard stores `WAITING_OFFPEAK` and holds that same session at the request boundary. Local filesystem and other tool operations already in flight are allowed to settle; the plugin does not kill them when the clock changes. The next inference request waits until pricing is allowed and the plugin is ON again. +In `auto` mode, a supported request that reaches peak pricing first waits at OpenCode's pre-model lifecycle hook (`model.request`), which runs while the model request is being prepared and before OpenCode creates the provider attempt and its response-header timeout. Nothing is sent to the provider while the request waits; the same session and the same request continue the same turn automatically at the policy's next off-peak transition — no second message, no duplicate prompt, no new session, and no synthetic continuation. `/offpeak status` reports held requests (`Interactive: N request(s) waiting for off-peak`). The hold lives in the OpenCode background server process and wakes on the scheduled pricing boundary, not on another message or command. -In `auto` mode, a normal interactive request for a supported model is held the same way. The request you already sent stays pending — no new session, no duplicate prompt, and no second `continue` — and the same OpenCode session proceeds automatically at the policy's next off-peak transition. `/offpeak status` reports held interactive requests while they wait. The hold lives in the OpenCode background server process and wakes on the scheduled pricing boundary, not on another message or command. If the plugin shuts down or the server restarts while a request is held, the request fails closed as blocked instead of being sent under an uncertain pricing state; resend it after the server is back. Requests that arrive while the plugin is OFF, or in `queue`/`guard` mode, are blocked immediately. +A queued `auto` task behaves the same way: if it reaches peak pricing before its next provider request, the guard stores `WAITING_OFFPEAK` and the same session waits and continues. Local filesystem and other tool operations already in flight are allowed to settle; the plugin does not kill them when the clock changes. + +The HTTP and experimental WebSocket transport guard still runs immediately before OpenCode sends anything, as the final hard barrier. Requests that arrive while the plugin is OFF, or in `queue`/`guard` mode, are blocked by that guard exactly as before. If the plugin shuts down or the server restarts while a request is waiting, the request fails closed as blocked instead of being sent under an uncertain pricing state; the OpenCode server's own execution-claim recovery resumes sessions interrupted by a crash, and the plugin continues to enforce pricing when they come back. + +If OpenCode itself interrupts a waiting session (its location-activity sweep ends a session that has had no durable events for 60 minutes), the plugin keeps the persisted deferral and continues the same session automatically at the next allowed window. A user cancel clears the pending deferral instead, so nothing continues after you cancel. ### Timeout and retry recovery -A request can start during off-peak, cross into a peak window, and then fail with a retryable provider or network error such as `Timed out waiting for response headers`. OpenCode normally retries such failures, and a retry attempt is itself a new paid request: during peak pricing it must not reach the provider, and the work must not be lost when OpenCode's retry budget runs out. +A request can start during off-peak, be accepted by the provider, cross into a peak window, and then fail with a retryable provider or network error such as `Timed out waiting for response headers`. That failure is real (the request was already sent), so the plugin's job is to make sure nothing further reaches the provider and the work is not lost. OpenCode normally retries such failures, and a retry attempt is itself a new paid request: during peak pricing it must not reach the provider, and the work must not be lost when OpenCode's retry budget runs out. `opencode-offpeak` registers OpenCode's supported `retry` hook. When a retryable failure happens while pricing is blocked and the plugin is ON in `auto` mode, the plugin vetoes the retry (`decision = { retry: false }`), so no retry attempt reaches the provider. The work is then marked to continue at the next allowed window: @@ -81,19 +85,27 @@ After a machine restart, queue metadata remains in OpenCode's local plugin stora Set `mode` in `~/.config/opencode/offpeak.json`: -- `auto` (default): start queued tasks off-peak, automatically resume a paused same-session task, hold normal interactive supported-model requests at the pricing boundary until off-peak returns, and stop retryable provider failures during peak so the same session continues at the next off-peak window. -- `queue`: start queued tasks off-peak; a task paused at peak waits for `/offpeak resume ` or `/offpeak run-now `; interactive peak requests are blocked immediately. -- `guard`: enforce request pricing for interactive OpenCode sessions, but do not automatically start queued tasks or hold interactive requests for a later window. +- `auto` (default): start queued tasks off-peak, automatically resume a paused same-session task, defer normal interactive supported-model requests at the pre-model hook until off-peak returns, and stop retryable provider failures during peak so the same session continues at the next off-peak window. +- `queue`: start queued tasks off-peak; a task paused at peak waits for `/offpeak resume ` or `/offpeak run-now `; interactive peak requests are blocked immediately by the transport guard. +- `guard`: enforce request pricing for interactive OpenCode sessions, but do not automatically start queued tasks or defer interactive requests for a later window. All queue automation respects the persistent `enabled` setting. The guard itself does not turn OFF into a price bypass. ## Pricing policy -The built-in policy ID is `deepseek-opencode-go`. OpenCode Go currently lists peak hours as 01:00–04:00 and 06:00–10:00 UTC, Monday through Friday; all other hours, including weekends, are off-peak. Its current Go price table lists peak DeepSeek rates at twice their off-peak per-token rates. Since Go limits are denominated in dollars, equal token use therefore consumes the allowance twice as fast during peak hours; that allowance effect follows from the published rate and limit tables. The policy follows the OpenCode Go schedule, not the separate DeepSeek API holiday wording. +The built-in policy ID is `deepseek-opencode-go`. Its canonical rules are: + +- Peak: Monday–Friday 01:00–04:00 and 06:00–10:00 UTC. +- Off-peak: all other hours, weekends, and Chinese statutory public holidays in full. +- Weekend make-up working days stay off-peak: DeepSeek defines weekends as off-peak, so an adjusted Saturday or Sunday is never peak. + +Holiday dates come from the official State Council calendar kept in `src/holidays.ts` (year, exception dates, source URL, verification date, description). The full-day off-peak exception overrides every weekday peak window, evaluated on the calendar's own time zone (`Asia/Shanghai`), so the host-local time zone never matters. Years without a holiday calendar keep the deterministic weekly schedule; no future dates are guessed. The holiday calendar requires one data update per year, published by the State Council the previous November. + +The direct DeepSeek pricing documentation defines the exemption ("…excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full."), and the OpenCode Go pricing page documents the same weekly windows for this provider. The Go price table lists peak DeepSeek rates at twice their off-peak per-token rates, so equal token use consumes the allowance twice as fast during peak hours. Re-verify both pages annually; upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) tracks an open user report about National Day holiday billing and is the cue for a fresh check. The built-in model IDs are `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, and `deepseek-v4-flash-vision-exp` under provider ID `opencode-go`. An unrecognized model under a configured provider fails closed. A provider with no policy is shown as `UNTRACKED`; queued tasks require a matching policy. -Pricing details can change. This release verified the built-in schedule against the [OpenCode Go pricing page](https://opencode.ai/docs/go/) on 2026-10-09. Review that source before relying on a later policy update. +Pricing details can change. This release verified the built-in schedule and the 2026 holiday calendar against the [OpenCode Go pricing page](https://opencode.ai/docs/go/), the [DeepSeek pricing documentation](https://api-docs.deepseek.com/quick_start/pricing), and the [State Council notice](https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html) on 2026-10-14. Review those sources before relying on a later policy update. ## Configuration @@ -125,9 +137,10 @@ See [configuration](docs/configuration.md) and [pricing policies](docs/pricing-p - A task cannot execute while the OpenCode server is stopped. It resumes when a server loads the plugin and queue state. - OpenCode documents session crash recovery as at-least-once. If a task persisted as `RUNNING` when the server restarted, this plugin does not automatically replay it: it marks the task `FAILED`, preserves its session ID, and requires an explicit `/offpeak retry`. That is the safe choice when the provider may have received a paid request before the crash. - A `WAITING_OFFPEAK` task is safe to resume automatically because the guard persisted that state before allowing another provider request. In `auto` mode it continues with the same session ID. -- A retryable provider failure during peak never retries into the provider. OpenCode's agent-loop request is retried up to three times with jittered backoff (attempts 2–4, denied at attempt 5) for retryable transport failures; the plugin observes every retry decision through the supported `retry` hook. +- A retryable provider failure during peak never retries into the provider. OpenCode's agent-loop request is retried with jittered exponential backoff; header-timeout failures are bounded by OpenCode to three retries (attempts 2–4, denied at attempt 5). The plugin observes every retry decision through the supported `retry` hook. - The continuation prompt asks the model to continue from the current project state; the plugin does not replay the exact failed provider request. A transport failure can be ambiguous (the provider may have received the request before the error), which is why the continuation is a state-aware new turn instead of a byte-for-byte replay. -- An interactive request held at the boundary lives in the running OpenCode server process; a plugin/server shutdown fails it closed as blocked rather than sending it under an uncertain pricing state. A retryable provider failure during peak instead records minimal recovery metadata in OpenCode plugin storage, so the same session continues at the next off-peak window even across a reload. +- A request deferred at the pre-model hook lives in the running OpenCode server process; a plugin/server shutdown fails it closed as blocked rather than sending it under an uncertain pricing state. A retryable provider failure during peak instead records minimal recovery metadata in OpenCode plugin storage, so the same session continues at the next off-peak window even across a reload. +- If OpenCode's own location-activity sweep interrupts a deferred session (no durable session events for 60 minutes), the plugin records the same persisted recovery and the session continues at the next allowed window; user interrupts are never converted into recoveries. - A session that never settles after a retryable failure (no outcome is recorded) is not continued automatically; its recovery record is dropped without sending anything rather than injecting a message into an unclear session. - The plugin blocks at the provider request boundary. It does not roll back a request already accepted by a provider or interrupt an already-running local tool operation. - The policy protects requests routed through OpenCode's v2 HTTP and experimental WebSocket model hooks. It does not control calls made by other programs. diff --git a/RELEASING.md b/RELEASING.md index a711cc0..cee28b8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,13 +2,13 @@ ## Current public distribution -`v0.1.3` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2: +`v0.1.4` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2: ```sh -opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.3 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.4 ``` -`v0.1.0`, `v0.1.1`, and `v0.1.2` remain available at their own tags, but `v0.1.3` is the canonical release: it adds peak-aware retry recovery (a retryable provider failure during peak is stopped before it reaches the provider and the same session continues at the next off-peak window) on top of the v0.1.2 interactive boundary hold, the v0.1.1 compatibility enforcement, the documented `Plugin.define(...)` TUI shape, `/offpeak remove`, and the real-TUI regression smoke test. OpenCode officially accepts npm-compatible Git package specifications, including GitHub URLs and exact tags ([plugin installation docs](https://opencode.ai/v2/docs/plugins)). This install path loads both the server and TUI entrypoints. npm publication is optional future distribution; npm login, 2FA, or registry publication is not required before this release can be installed or used. +`v0.1.0`, `v0.1.1`, `v0.1.2`, and `v0.1.3` remain available at their own tags, but `v0.1.4` is the canonical release: it moves peak waiting to OpenCode's pre-model lifecycle hook (`model.request`), so a request submitted during peak no longer holds a provider attempt open or surfaces `Timed out waiting for response headers`, and the same session continues the same turn at the boundary; it implements the Chinese statutory public holidays as full-day OFF_PEAK via explicit holiday exception calendars with the official 2026 State Council calendar; the transport guard remains the final hard barrier, the v0.1.3 retry recovery stays for requests that fail after crossing into peak, and origin-aware interrupt handling keeps deferred sessions recoverable across reloads. This sits on top of the v0.1.2 interactive boundary hold, the v0.1.1 compatibility enforcement, the documented `Plugin.define(...)` TUI shape, `/offpeak remove`, and the real-TUI regression smoke test. OpenCode officially accepts npm-compatible Git package specifications, including GitHub URLs and exact tags ([plugin installation docs](https://opencode.ai/v2/docs/plugins)). This install path loads both the server and TUI entrypoints. npm publication is optional future distribution; npm login, 2FA, or registry publication is not required before this release can be installed or used. ## Optional npm distribution diff --git a/docs/concepts.md b/docs/concepts.md index b0a368c..bb69a6d 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -10,13 +10,13 @@ Turning the plugin OFF changes only automation. It does not change pricing evalu ## Modes -`auto` starts and resumes queued tasks when policy allows, and holds normal interactive peak requests until off-peak returns. `queue` starts queued tasks when allowed but waits for a manual `/offpeak resume` after a peak pause, and blocks interactive peak requests immediately. `guard` enforces interactive model requests without automatically starting queued tasks or holding interactive requests for a later window. +`auto` starts and resumes queued tasks when policy allows, and defers normal interactive peak requests until off-peak returns. `queue` starts queued tasks when allowed but waits for a manual `/offpeak resume` after a peak pause, and blocks interactive peak requests immediately. `guard` enforces interactive model requests without automatically starting queued tasks or deferring interactive requests for a later window. ## Boundary behavior -The plugin checks immediately before OpenCode's provider HTTP/WebSocket transport. A request already accepted by the provider cannot be withdrawn. Local tool operations already running are allowed to settle; the next paid model request is the enforcement boundary. +Pricing is enforced at two lifecycle points. A `model.request` hook runs while OpenCode prepares each model request, before the provider attempt and its response-header timeout exist. In `auto` mode a peak-priced request is deferred there: nothing is sent while pricing is blocked, and the same session continues the same turn when off-peak returns. The HTTP/WebSocket transport guard still runs immediately before the provider transport as the final hard barrier, and `queue`/`guard`/OFF requests are blocked there exactly as before. A request already accepted by the provider cannot be withdrawn. Local tool operations already running are allowed to settle; the next paid model request is the enforcement boundary. -In `auto` mode, an interactive message sent during peak pricing is held at that boundary, and the same session continues automatically when off-peak returns; no second message is needed. The release comes from the scheduled pricing transition (or a config change), never from another message, a status command, or a manual wake-up. `queue`/`guard` modes and the OFF state block interactive peak requests immediately, and a plugin shutdown fails a held request closed rather than sending it. +In `auto` mode, an interactive message sent during peak pricing is deferred before the provider attempt and continues automatically when off-peak returns; no second message is needed, and no provider attempt or response-header timer is held open. The release comes from the scheduled pricing transition (or a config change), never from another message, a status command, or a manual wake-up. `queue`/`guard` modes and the OFF state block interactive peak requests immediately, and a plugin shutdown fails a deferred request closed rather than sending it. If OpenCode's own location-activity sweep ends a deferred session (60 minutes with no durable session events), the plugin records the persisted recovery described below. A request that is already in flight and fails with a retryable provider/network error (for example a header timeout) after peak pricing begins is recovered the same way. The plugin stops OpenCode's retry before it can reach the provider, then continues the same session at the next off-peak transition with a short continuation prompt: a queue task returns to `WAITING_OFFPEAK`, and an interactive session is reported under `Recovery` in `/offpeak status`. Non-retryable failures and failures while pricing is allowed keep their normal OpenCode behavior. diff --git a/docs/configuration.md b/docs/configuration.md index c7784c2..ebe59dd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,13 +13,13 @@ The setting file is `$OPENCODE_CONFIG_DIR/offpeak.json` when `OPENCODE_CONFIG_DI } ``` -If `policies` is omitted, the built-in DeepSeek/OpenCode Go policy is used. If you provide `policies`, treat the array as the full active policy set and include the built-in entry too if you still want DeepSeek Go protection. An empty array is invalid. Do not configure overlapping policies for the same provider/model. +If `policies` is omitted, the built-in DeepSeek/OpenCode Go policy is used. If you provide `policies`, treat the array as the full active policy set and include the built-in entry too if you still want DeepSeek Go protection. An empty array is invalid. Do not configure overlapping policies for the same provider/model. A stored policy that is otherwise identical to a built-in policy (same id, providers, models, time zone, multiplier, and weekly windows) adopts the built-in holiday calendar automatically, so yearly holiday updates apply without editing the file; any customized difference — including an explicit `holidays` list — is preserved exactly. ## Fields - `schemaVersion`: must be `1`. - `enabled`: boolean, default `true`. `/offpeak on|off` updates this same value. -- `mode`: `auto` (default), `queue`, or `guard`. In `auto`, normal interactive peak requests are held at the provider boundary and released when off-peak returns; `queue` and `guard` block interactive peak requests immediately. +- `mode`: `auto` (default), `queue`, or `guard`. In `auto`, normal interactive peak requests are deferred at OpenCode's pre-model hook and released when off-peak returns; `queue` and `guard` block interactive peak requests immediately at the transport guard. - `displayTimeZone`: valid IANA timezone for status output; defaults to the system timezone. - `policies`: complete list of pricing policy definitions. See [pricing policies](pricing-policies.md). diff --git a/docs/pricing-policies.md b/docs/pricing-policies.md index 63a4117..2f6adc8 100644 --- a/docs/pricing-policies.md +++ b/docs/pricing-policies.md @@ -23,7 +23,24 @@ The policy includes a source URL and verification date to make schedule provenan ## Built-in policy -`deepseek-opencode-go` matches provider ID `opencode-go` and the DeepSeek model IDs listed in the [README](../README.md). Its canonical schedule is defined only in `src/policy.ts`. It follows the current [OpenCode Go pricing page](https://opencode.ai/docs/go/); it does not assume the separate direct DeepSeek API holiday schedule applies to OpenCode Go. +`deepseek-opencode-go` matches provider ID `opencode-go` and the DeepSeek model IDs listed in the [README](../README.md). Its canonical schedule is defined only in `src/policy.ts`, with the holiday calendar in `src/holidays.ts`. + +The rules are: peak Monday–Friday 01:00–04:00 and 06:00–10:00 UTC; off-peak all other hours, weekends, and Chinese statutory public holidays in full; weekend make-up working days stay off-peak because weekends are always off-peak. + +Holiday support uses explicit full-day OFF_PEAK exception calendars on the policy: + +```json +{ + "year": 2026, + "timeZone": "Asia/Shanghai", + "dates": ["2026-10-01", "2026-10-02"], + "source": "https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html", + "verifiedAt": "2026-10-14", + "description": "Chinese statutory public holidays; State Council notice" +} +``` + +A listed date overrides every weekday peak window for the whole calendar day in the calendar's own time zone. The built-in 2026 calendar comes from the official State Council notice; `nextTransition` skips holiday peak windows; a year without a calendar keeps the deterministic weekly schedule, and no future dates are guessed. The State Council publishes the next year's arrangement each November, so the calendar needs one data update per year. The rule follows the DeepSeek pricing documentation referenced by the OpenCode Go pricing page; upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) tracks an open report about National Day billing and is the cue for the annual re-check. An unknown model under provider `opencode-go` returns `UNKNOWN` and fails closed. Add a verified model/policy entry before relying on newly added provider models. diff --git a/docs/queue.md b/docs/queue.md index 16a4e76..205a65b 100644 --- a/docs/queue.md +++ b/docs/queue.md @@ -14,7 +14,7 @@ Queueing stores the prompt, project working directory, current provider/model, p - `QUEUED`: waiting for an eligible pricing window or for the plugin to be enabled. - `RUNNING`: attached to a session that is actively processing. -- `WAITING_OFFPEAK`: same task/session is paused at the request boundary or awaiting an allowed window. +- `WAITING_OFFPEAK`: same task/session is paused at the pre-model or transport guard or awaiting an allowed window. - `COMPLETED`: OpenCode reports the stored session outcome as succeeded. - `FAILED`: session failure, invalid task policy, or ambiguous crash recovery; inspect `lastError` and explicitly retry. - `CANCELLED`: user explicitly cancelled the task. The session ID remains available in task history. diff --git a/docs/releases/v0.1.4.md b/docs/releases/v0.1.4.md new file mode 100644 index 0000000..7ee716e --- /dev/null +++ b/docs/releases/v0.1.4.md @@ -0,0 +1,46 @@ +# opencode-offpeak v0.1.4 + +Released 2026-10-14. `v0.1.0`, `v0.1.1`, `v0.1.2`, and `v0.1.3` remain at their own tags and are not modified. + +## Fix: peak waiting no longer holds a provider attempt open + +OpenCode 2.0.27 starts an attempt's response-header timeout (default 5 minutes) at attempt start, before the `http.request` hook chain completes. The v0.1.2–v0.1.3 guard held interactive peak requests inside `http.request`, so a request submitted during peak could sit in the transport attempt until the header timeout fired and the terminal showed `Error: Timed out waiting for response headers`. + +The deferral now happens at `model.request`, a supported v2 session hook that runs while the model request is being prepared — after the agent/context hooks and before the provider attempt (and its timeout) exists. In `auto` mode, a peak request for a covered policy waits there; nothing reaches the provider while pricing is blocked, and at the next off-peak transition the same session continues the same turn. There is no timeout to outlast, no retry used as the waiting mechanism, no synthetic continuation prompt, no duplicate user prompt, and no new session. + +- **The transport guard is unchanged and remains the final hard barrier** immediately before the HTTP request or WebSocket frame is sent. `queue`/`guard`/OFF modes still block interactive peak requests immediately through that guard, and UNKNOWN still fails closed. +- **Cross-boundary failures keep the v0.1.3 recovery.** A request that started off-peak and fails with a retryable transport error during peak still has its retry vetoed (`decision = { retry: false }`) and continues in the same session at the next allowed window. +- **Reload/restart safety.** A held primary interactive request persists a deferral while it waits and removes it on normal release; a plugin reload or server restart during peak still delivers exactly one same-session continuation once the allowed window arrives. +- **Origin-aware interrupts.** OpenCode's `session.execution.interrupted` events are handled by reason: `inactivity` keeps the deferral and continues the same session later; `user` clears a deferral and aborts the hold; `superseded`/`shutdown` abort the hold and leave the outcome to OpenCode's own recovery. No new daemon, database, scheduler, or polling loop is added — the existing signal, pump, and boundary timer drive every continuation. + +## Pricing policy: Chinese statutory public holidays are full-day OFF_PEAK + +The built-in `deepseek-opencode-go` policy now implements the complete pricing rule: + +- Peak: Monday–Friday 01:00–04:00 and 06:00–10:00 UTC. +- Off-peak: all other hours, weekends, and Chinese statutory public holidays in full. +- Weekend make-up working days stay OFF_PEAK (weekends are always off-peak). + +The policy model supports explicit full-day OFF_PEAK exception calendars (`year`, `dates`, `timeZone`, `source`, `verifiedAt`, `description`). A listed date overrides every weekday peak window for that whole calendar day, evaluated on the calendar's own time zone, so the host-local time zone never matters. The 2026 China calendar ships in `src/holidays.ts`, sourced from the official State Council notice (国办发明电〔2025〕7号, published 2025-11-04): Jan 1–3, Feb 15–23, Apr 4–6, May 1–5, Jun 19–21, Sep 25–27, Oct 1–7. `nextTransition` skips holiday peak windows (the scan covers up to 14 days so long holiday runs cannot hide the next real transition). A year without a calendar keeps the deterministic weekly schedule; no future dates are guessed. Annual maintenance is required — the State Council publishes the next year's arrangement each November. + +The rule comes from the DeepSeek pricing documentation ("…excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full.", ) that the OpenCode Go pricing page references for this provider's peak-hours definition (). A stored `policies` array that is an unmodified copy of the built-in policy adopts the built-in holiday calendar when read, so existing installations pick up yearly holiday updates without editing their config; customized policies and explicit holiday lists are preserved. The October 1–7 OpenCode Go billing comparison is not exposed per request — the provider offers only aggregate usage percentages — so upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) (open user report about National Day billing) is recorded as the cue for the annual re-check. See `DECISIONS.md`. + +## Why the old error appeared, in evidence + +- OpenCode 2.0.27 source: the header timeout wraps `runtime.http.execute(...)` inside the transport `execute` (`packages/ai/src/route/transport/http.ts`), and the `http.request` plugin hook is invoked inside that wrapped call (`packages/core/src/session/model-request.ts`). The timeout is `request.http?.headerTimeout ?? 300_000`ms, configured per provider. +- `model.request` is triggered in `SessionModelRequest.prepare` before `LLM.stream` is called, so no attempt timeout is running while the hook waits. +- Live reproduction: an interactive request submitted during the 06:00–10:00 UTC peak window failed exactly 5 minutes later with `provider.transport: Timed out waiting for response headers`; the v0.1.3 retry hook vetoed the retry and the recovery continued the session at the 10:00 UTC boundary. The visible error was the reported symptom; the deferral point is the fix. + +## Install + +```sh +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.4 +``` + +Then restart OpenCode (a long-running TUI keeps its old code until it restarts). + +## Checks + +- `npm run lint`, `npm run typecheck`, `npm test` (70 tests), `npm pack --dry-run`. +- New deterministic regression tests cover: the pre-model deferral released by the scheduled boundary timer with zero provider traffic, passivity outside `auto`/ON/peak and for untracked/unknown models, the queue-owned deferral continuing the same session without a continuation prompt, deferral persistence across a plugin reload, user/inactivity/shutdown interrupt semantics, and the holiday policy (every 2026 holiday period off-peak in both peak windows, make-up workdays off-peak, adjacent days normal, `nextTransition` skipping holidays, calendar-time-zone semantics, unknown years deterministic, validation fail-closed). +- Isolated end-to-end runs on the real OpenCode 2.0.27 binary with a local fixture provider and a short local policy window: an interactive request submitted during a simulated peak was deferred past the provider's configured header timeout (330s hold against a 5s timeout), produced **zero fixture requests during the peak**, was released at the boundary in the **same session**, and completed with exactly one user message, exit code 0, and no error; the same harness against v0.1.3 fails at 5s with `Timed out waiting for response headers`. A queue-owned task queued during peak waited in `WAITING_OFFPEAK`, auto-started exactly at the boundary, and completed in one session with zero peak traffic. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e9ce9e6..fe10b80 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,7 +2,7 @@ ## `/offpeak` is not recognized -Confirm the installed CLI is within `>=2.0.26 <2.1.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.3`, then restart OpenCode. Check `opencode plugin list` and the OpenCode server logs. The server entrypoint provides RPC while the TUI entrypoint registers the slash command. +Confirm the installed CLI is within `>=2.0.26 <2.1.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.4`, then restart OpenCode. Check `opencode plugin list` and the OpenCode server logs. The server entrypoint provides RPC while the TUI entrypoint registers the slash command. ## `Keymap.Provider is missing` in the TUI @@ -20,11 +20,11 @@ If you intentionally accept peak pricing for a queued task, turn the plugin ON a ## Interactive message waits at peak -In `auto` mode, a supported interactive request sent during a peak window is held at the provider boundary instead of being discarded, and the same session proceeds automatically when off-peak returns; no second message is needed. `/offpeak status` shows it under `Interactive` while it waits. In `queue` or `guard` mode, and while the plugin is OFF, interactive peak requests are blocked immediately. If a held request fails closed because the plugin or server shut down, resend it after the server is back. +In `auto` mode, a supported interactive request sent during a peak window is deferred at OpenCode's pre-model hook — before any provider attempt (and its response-header timeout) starts — instead of being discarded or timing out, and the same session proceeds automatically when off-peak returns; no second message is needed. `/offpeak status` shows it under `Interactive` while it waits. In `queue` or `guard` mode, and while the plugin is OFF, interactive peak requests are blocked immediately by the transport guard. If a deferred request fails closed because the plugin or server shut down, resend it after the server is back. ## Request timed out during peak and the session continued later -In `auto` mode, a retryable provider/network failure that happens while peak pricing is active does not lose the work. The plugin vetoes OpenCode's retry before it can reach the provider and continues the same session at the next off-peak transition with a short continuation prompt. `/offpeak status` reports `Recovery: N session(s) waiting for off-peak` for interactive sessions, and queue tasks show as `WAITING_OFFPEAK` instead of `FAILED`. If you see an error such as `Timed out waiting for response headers` during peak, no action is needed; the session resumes automatically when pricing allows. Non-retryable errors (for example invalid requests or authentication failures) still require you to fix the cause and retry explicitly. +If a request was already in flight when peak pricing began and failed with a retryable provider/network error such as `Timed out waiting for response headers`, that failure is real — the request had already been sent. In `auto` mode the plugin vetoes OpenCode's retry before it can reach the provider and continues the same session at the next off-peak transition with a short continuation prompt. `/offpeak status` reports `Recovery: N session(s) waiting for off-peak` for interactive sessions, and queue tasks show as `WAITING_OFFPEAK` instead of `FAILED`. No action is needed; the session resumes automatically when pricing allows. If OpenCode itself ends a waiting session (its 60-minute location-activity sweep), the same recovery applies. Non-retryable errors (for example invalid requests or authentication failures) still require you to fix the cause and retry explicitly. ## Task is waiting diff --git a/package.json b/package.json index 644e5e2..8713212 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-offpeak", - "version": "0.1.3", + "version": "0.1.4", "description": "Queue OpenCode agent tasks and guard model requests against expensive pricing windows.", "author": "Lutz Kind", "license": "MIT", diff --git a/src/config.ts b/src/config.ts index 260401a..620c9b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -85,10 +85,43 @@ export function validateConfig(value: unknown, fallback: OffpeakConfig): Offpeak enabled: value.enabled, mode: value.mode, displayTimeZone, - policies: structuredClone(configuredPolicies), + policies: structuredClone(adoptBuiltinHolidays(configuredPolicies, fallback.policies)), }; } +/** + * A stored policies array that is an unmodified copy of a built-in policy + * adopts the built-in full-day OFF_PEAK calendars (for example the yearly + * Chinese holiday list) without touching any customized schedule. A policy + * that differs in provider, model, time zone, multiplier, or weekly windows is + * preserved exactly as stored, and an explicit holiday list always wins. + */ +function adoptBuiltinHolidays(stored: PricingPolicy[], builtin: PricingPolicy[]): PricingPolicy[] { + return stored.map((policy) => { + const reference = builtin.find((candidate) => candidate.id === policy.id); + if (!reference?.holidays?.length || policy.holidays !== undefined) return policy; + if (!sameWeeklySchedule(policy, reference)) return policy; + return { ...policy, holidays: structuredClone(reference.holidays) }; + }); +} + +function sameWeeklySchedule(left: PricingPolicy, right: PricingPolicy): boolean { + const normalize = (policy: PricingPolicy) => ({ + providers: [...policy.providers].sort(), + models: [...policy.models].sort(), + timeZone: policy.timeZone, + multiplier: policy.multiplier, + blocked: policy.blocked + .map((window) => ({ ...window, days: [...window.days].sort() })) + .sort((leftWindow, rightWindow) => + `${leftWindow.start}-${leftWindow.end}`.localeCompare( + `${rightWindow.start}-${rightWindow.end}`, + ), + ), + }); + return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right)); +} + export class ConfigRepository { readonly path: string; readonly #fallback: OffpeakConfig; diff --git a/src/engine.ts b/src/engine.ts index 0664940..5e45007 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -63,7 +63,13 @@ export interface GuardInput { sessionID: string; providerID: string; modelID: string; - transport: "http" | "websocket"; + transport: "http" | "websocket" | "model"; + /** + * OpenCode session request kind ("primary", "compaction", "title", "generate") + * when the guard runs from the pre-model `model.request` hook. Transport + * guards do not carry a kind. + */ + kind?: string; } /** @@ -128,6 +134,12 @@ export class OffpeakEngine { readonly #signal: AsyncSignal; readonly #runners = new Set(); readonly #interactiveWaiters = new Set(); + /** + * Per-session monotonic abort epochs. When OpenCode interrupts a session's + * execution (user cancel, inactivity sweep, supersede), a hold loop whose + * epoch moved on fails closed instead of lingering as a stale waiter. + */ + readonly #holdAborts = new Map(); readonly #recoveriesRepo: RecoveryRepository | undefined; readonly #location: string; readonly #recoveries = new Map(); @@ -379,6 +391,28 @@ export class OffpeakEngine { async guardRequest(input: GuardInput): Promise { // Any fresh request for this session supersedes a pending recovery continuation. await this.#removeRecovery(input.sessionID); + await this.#guardPricing(input, false); + } + + /** + * Pre-model guard for OpenCode's `model.request` hook. It runs while the + * session request is being prepared, before any provider attempt (and before + * OpenCode's response-header timeout clock) exists, so holding for OFF_PEAK + * here never leaves a provider attempt open. + * + * A held primary request of an interactive session is persisted as a + * deferral: if the plugin reloads or the server restarts while it waits, the + * existing recovery flush delivers one same-session continuation once the + * allowed window arrives. The release itself removes the deferral, so normal + * completion never sends a continuation. + */ + async guardModelRequest(input: GuardInput): Promise { + const primary = input.kind === "primary"; + if (primary) await this.#removeRecovery(input.sessionID); + await this.#guardPricing(input, primary); + } + + async #guardPricing(input: GuardInput, persistDeferral: boolean): Promise { const task = await this.#taskForSession(input.sessionID); const first = await this.#config.read(); if (!first.config) @@ -398,14 +432,14 @@ export class OffpeakEngine { if (task && first.config.mode === "auto") { await this.#transition(task.id, "WAITING_OFFPEAK"); - await this.#holdUntilAllowed(input, task); + await this.#holdUntilAllowed(input, task, false); return; } // A normal interactive request in auto mode is held at the boundary instead of // being discarded; the same request and session continue when pricing allows. if (!task && first.config.enabled && first.config.mode === "auto") { - await this.#holdUntilAllowed(input); + await this.#holdUntilAllowed(input, undefined, persistDeferral); return; } @@ -422,19 +456,40 @@ export class OffpeakEngine { /** * Holds a request at the provider transport boundary until the plugin is enabled - * and the pricing policy allows it. Used by queue-owned auto tasks and by normal - * interactive auto-mode requests. + * and the pricing policy allows it. Used by queue-owned auto tasks, by normal + * interactive auto-mode requests, and by the pre-model guard. * * The hard guarantee: the caller never crosses the transport boundary while * pricing is blocked. Wake-ups come from the shared config signal and one timer * for the policy's next transition. Queue tasks move through WAITING_OFFPEAK and * may use their explicit override; interactive requests are tracked in memory * (for status) and fail closed on shutdown instead of guessing the price state. + * + * `persistDeferral` marks an interactive primary request that is safe to resume + * after a plugin reload or server restart: the recovery entry is persisted while + * the request waits and removed again when the wait ends normally. A shutdown or + * guard error leaves it in place so the continuation can be delivered once the + * allowed window arrives. */ - async #holdUntilAllowed(input: GuardInput, task?: OffpeakTask): Promise { + async #holdUntilAllowed( + input: GuardInput, + task?: OffpeakTask, + persistDeferral = false, + ): Promise { const waiter = task ? undefined : this.#registerInteractiveWaiter(); + const deferred = persistDeferral && !task; + if (deferred) { + await this.#registerRecovery(input.sessionID, input.providerID, input.modelID, "deferred"); + this.#requestPump(); + } + const abortEpoch = this.#holdAborts.get(input.sessionID) ?? 0; try { while (!this.#disposed) { + if ((this.#holdAborts.get(input.sessionID) ?? 0) !== abortEpoch) { + throw new PricingBlockedError( + "Offpeak hold was interrupted in OpenCode; the request was not sent.", + ); + } const signalVersion = this.#signal.version(); const settings = await this.#config.read(); if (!settings.config) @@ -459,6 +514,7 @@ export class OffpeakEngine { } if (settings.config.enabled && decision.allowed) { if (task) await this.#transition(task.id, "RUNNING"); + if (deferred) await this.#removeRecovery(input.sessionID); return; } if (settings.config.enabled && decision.nextTransition === undefined) { @@ -518,12 +574,42 @@ export class OffpeakEngine { this.#requestPump(); return "hold"; } - await this.#registerRecovery(input.sessionID, input.providerID, input.modelID); + await this.#registerRecovery(input.sessionID, input.providerID, input.modelID, "retry-failure"); this.#signal.notify(); this.#requestPump(); return "hold"; } + /** + * Called when OpenCode reports that a session's execution was interrupted. + * + * - "user": the user cancelled. A pending deferral is cleared and the hold is + * aborted so nothing continues automatically. + * - "inactivity": OpenCode's location-activity sweep ended the execution. The + * hold is aborted and a deferral (if present) is kept so the same session + * continues once pricing allows. + * - "superseded"/"shutdown": the hold is aborted; the session's own claim or + * restart recovery owns what happens next. + */ + async noteExecutionInterrupted(input: { sessionID: string; reason: string }): Promise { + if (this.#disposed) return; + if (input.reason === "user") { + const entry = this.#recoveries.get(input.sessionID); + if (entry?.origin === "deferred") await this.#removeRecovery(input.sessionID); + } + if ( + input.reason !== "user" && + input.reason !== "inactivity" && + input.reason !== "superseded" && + input.reason !== "shutdown" + ) { + return; + } + this.#holdAborts.set(input.sessionID, (this.#holdAborts.get(input.sessionID) ?? 0) + 1); + this.#signal.notify(); + this.#requestPump(); + } + async #loadRecoveries(): Promise { const repo = this.#recoveriesRepo; if (!repo) return; @@ -534,7 +620,12 @@ export class OffpeakEngine { } } - async #registerRecovery(sessionID: string, providerID: string, modelID: string): Promise { + async #registerRecovery( + sessionID: string, + providerID: string, + modelID: string, + origin: "retry-failure" | "deferred", + ): Promise { if (this.#recoveries.has(sessionID)) return; this.#recoveries.set(sessionID, { sessionID, @@ -542,6 +633,7 @@ export class OffpeakEngine { modelID, directory: this.#location, heldAt: new Date(this.#clock.now()).toISOString(), + origin, }); await this.#persistRecoveries(); } diff --git a/src/holidays.ts b/src/holidays.ts new file mode 100644 index 0000000..c37a809 --- /dev/null +++ b/src/holidays.ts @@ -0,0 +1,86 @@ +/** + * Full-day OFF_PEAK exception calendars for the built-in pricing policies. + * + * Chinese statutory public holidays are OFF_PEAK all day under the DeepSeek / + * OpenCode Go pricing rules, even when they fall on a weekday inside a weekly + * PEAK window. Official weekend make-up working days are not modelled: DeepSeek + * defines weekends as off-peak, so an adjusted Saturday or Sunday stays + * OFF_PEAK. + * + * The State Council publishes the next year's arrangement in the previous + * November, so this file needs one data update per year. A year without a + * calendar keeps the normal weekday/weekend schedule deterministically; it + * never guesses future holidays. + */ + +export interface HolidayCalendar { + /** Calendar year the dates belong to, evaluated in `timeZone`. */ + year: number; + /** IANA time zone whose calendar dates this list is defined in. */ + timeZone: string; + /** Full-day OFF_PEAK dates, `YYYY-MM-DD` in `timeZone`. */ + dates: string[]; + /** Authoritative source URL for the calendar. */ + source: string; + /** Date the list was verified against the source. */ + verifiedAt?: string; + /** Human description of the list and its authority. */ + description?: string; +} + +/** + * Chinese statutory public holidays for 2026. + * + * Source: 国务院办公厅关于2026年部分节假日安排的通知 (国办发明电〔2025〕7号), + * published 2025-11-04. Holiday periods: Jan 1-3, Feb 15-23, Apr 4-6, + * May 1-5, Jun 19-21, Sep 25-27, Oct 1-7. + * + * Adjusted weekend workdays (Jan 4, Feb 14, Feb 28, May 9, Sep 20, Oct 10) + * are intentionally not part of this list; they remain OFF_PEAK as weekends. + */ +export const CHINA_PUBLIC_HOLIDAYS_2026: HolidayCalendar = { + year: 2026, + timeZone: "Asia/Shanghai", + dates: [ + "2026-01-01", + "2026-01-02", + "2026-01-03", + "2026-02-15", + "2026-02-16", + "2026-02-17", + "2026-02-18", + "2026-02-19", + "2026-02-20", + "2026-02-21", + "2026-02-22", + "2026-02-23", + "2026-04-04", + "2026-04-05", + "2026-04-06", + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + "2026-05-05", + "2026-06-19", + "2026-06-20", + "2026-06-21", + "2026-09-25", + "2026-09-26", + "2026-09-27", + "2026-10-01", + "2026-10-02", + "2026-10-03", + "2026-10-04", + "2026-10-05", + "2026-10-06", + "2026-10-07", + ], + source: "https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html", + verifiedAt: "2026-10-14", + description: + "Chinese statutory public holidays; State Council notice 国办发明电〔2025〕7号 (2025-11-04)", +}; + +/** Holiday calendars available to the built-in policies, newest last. */ +export const CHINA_PUBLIC_HOLIDAYS: HolidayCalendar[] = [CHINA_PUBLIC_HOLIDAYS_2026]; diff --git a/src/policy.ts b/src/policy.ts index 7fd0803..7b580aa 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -1,3 +1,5 @@ +import { CHINA_PUBLIC_HOLIDAYS, type HolidayCalendar } from "./holidays.js"; + export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6; export interface WeeklyWindow { @@ -13,6 +15,12 @@ export interface PricingPolicy { models: string[]; timeZone: string; blocked: WeeklyWindow[]; + /** + * Full-day OFF_PEAK exception calendars, for example statutory public + * holidays. A date listed in a calendar overrides every weekly PEAK window + * for that whole calendar day in the calendar's time zone. + */ + holidays?: HolidayCalendar[]; multiplier?: number; source: string; verifiedAt?: string; @@ -26,6 +34,14 @@ export interface PolicyDecision { reason?: string; } +// DeepSeek's official pricing documentation states the peak windows and the +// holiday rule: "Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday +// through Friday, excluding Chinese public holidays. All other hours are +// off-peak, including weekends and Chinese public holidays in full." +// (https://api-docs.deepseek.com/quick_start/pricing). The OpenCode Go pricing +// page documents the same windows for this provider +// (https://opencode.ai/docs/go/). Holiday dates come from the State Council +// calendar in src/holidays.ts. export const DEEPSEEK_OPENCODE_GO: PricingPolicy = { id: "deepseek-opencode-go", providers: ["opencode-go"], @@ -40,14 +56,19 @@ export const DEEPSEEK_OPENCODE_GO: PricingPolicy = { { days: [1, 2, 3, 4, 5], start: "01:00", end: "04:00" }, { days: [1, 2, 3, 4, 5], start: "06:00", end: "10:00" }, ], + holidays: CHINA_PUBLIC_HOLIDAYS, multiplier: 2, source: "https://opencode.ai/docs/go/", - verifiedAt: "2026-10-09", + verifiedAt: "2026-10-14", }; const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const MINUTE_MS = 60_000; -const MAX_LOOKAHEAD_MINUTES = 8 * 24 * 60; +// A transition is normally less than a week away; the longest Chinese holiday +// run (the Spring Festival) can push the next weekly PEAK window just past a +// week, so the scan looks two weeks ahead before failing closed. +const MAX_LOOKAHEAD_DAYS = 14; +const MAX_LOOKAHEAD_MINUTES = MAX_LOOKAHEAD_DAYS * 24 * 60; export function validateTimeZone(timeZone: string): void { try { @@ -78,6 +99,12 @@ export function validatePolicy(policy: PricingPolicy): void { const end = minuteOfDay(window.end); if (start === end) throw new Error(`Policy ${policy.id} contains a zero-length window.`); } + if (policy.holidays !== undefined) { + if (!Array.isArray(policy.holidays)) { + throw new Error(`Policy ${policy.id} holiday calendars must be an array.`); + } + for (const calendar of policy.holidays) validateHolidayCalendar(policy.id, calendar); + } if ( policy.multiplier !== undefined && (!Number.isFinite(policy.multiplier) || policy.multiplier < 1) @@ -86,6 +113,42 @@ export function validatePolicy(policy: PricingPolicy): void { } } +function validateHolidayCalendar(policyID: string, calendar: HolidayCalendar): void { + const label = `Policy ${policyID} holiday calendar`; + if (!Number.isInteger(calendar.year)) throw new Error(`${label} year must be an integer.`); + if (typeof calendar.timeZone !== "string" || !calendar.timeZone.trim()) { + throw new Error(`${label} for ${calendar.year} requires an IANA time zone.`); + } + validateTimeZone(calendar.timeZone); + if (typeof calendar.source !== "string" || !calendar.source.trim()) { + throw new Error(`${label} for ${calendar.year} requires a source URL.`); + } + if (!Array.isArray(calendar.dates)) { + throw new Error(`${label} for ${calendar.year} dates must be an array.`); + } + for (const date of calendar.dates) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) throw new Error(`${label} date ${date} must be formatted as YYYY-MM-DD.`); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const probe = new Date(Date.UTC(year, month - 1, day)); + if ( + probe.getUTCFullYear() !== year || + probe.getUTCMonth() !== month - 1 || + probe.getUTCDate() !== day + ) { + throw new Error(`${label} date ${date} is not a real calendar date.`); + } + if (year !== calendar.year) { + throw new Error(`${label} date ${date} does not belong to calendar year ${calendar.year}.`); + } + } + if (new Set(calendar.dates).size !== calendar.dates.length) { + throw new Error(`${label} for ${calendar.year} contains duplicate dates.`); + } +} + function minuteOfDay(value: string): number { const match = /^(\d{2}):(\d{2})$/.exec(value); if (!match) throw new Error(`Invalid time ${value}; expected HH:mm.`); @@ -121,7 +184,45 @@ function localParts(epochMs: number, format: Intl.DateTimeFormat) { }; } +const holidayFormatters = new Map(); + +function holidayFormatter(timeZone: string): Intl.DateTimeFormat { + const cached = holidayFormatters.get(timeZone); + if (cached) return cached; + validateTimeZone(timeZone); + const format = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + holidayFormatters.set(timeZone, format); + return format; +} + +function calendarDate(epochMs: number, format: Intl.DateTimeFormat): string { + const parts = Object.fromEntries( + format.formatToParts(epochMs).map(({ type, value }) => [type, value]), + ); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +/** + * Full-day OFF_PEAK exceptions (for example Chinese statutory public + * holidays) override every weekly PEAK window for the whole calendar day in + * the calendar's own time zone, so the host-local time zone never matters. + */ +function isFullDayOffPeak(policy: PricingPolicy, epochMs: number): boolean { + for (const calendar of policy.holidays ?? []) { + if (calendar.dates.includes(calendarDate(epochMs, holidayFormatter(calendar.timeZone)))) { + return true; + } + } + return false; +} + function isBlockedAt(policy: PricingPolicy, epochMs: number, format: Intl.DateTimeFormat): boolean { + if (isFullDayOffPeak(policy, epochMs)) return false; const local = localParts(epochMs, format); return policy.blocked.some((window) => { const start = minuteOfDay(window.start); @@ -144,7 +245,9 @@ function nextTransition(policy: PricingPolicy, nowMs: number, currentBlocked: bo const candidate = first + minute * MINUTE_MS; if (isBlockedAt(policy, candidate, format) !== currentBlocked) return candidate; } - throw new Error(`No pricing transition found in the next eight days for ${policy.id}.`); + throw new Error( + `No pricing transition found in the next ${MAX_LOOKAHEAD_DAYS} days for ${policy.id}.`, + ); } function matches(pattern: string, modelID: string): boolean { diff --git a/src/server.ts b/src/server.ts index 64100f2..a2e0b4e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -47,6 +47,20 @@ export default Plugin.define({ onError: (error) => console.error("[opencode-offpeak]", error), }); const hooks = await Promise.all([ + // Pre-model pricing guard: runs before any provider attempt (and before + // OpenCode's response-header timeout clock) exists, so a request that must + // wait for OFF_PEAK holds no provider attempt open. Primary requests of + // interactive sessions persist a deferral so a reload or restart can + // deliver the same-session continuation. + context.session.hook("model.request", (request) => + engine.guardModelRequest({ + sessionID: request.sessionID, + providerID: request.model.providerID, + modelID: request.model.id, + kind: request.kind, + transport: "model", + }), + ), context.session.hook("http.request", (request) => engine.guardRequest({ sessionID: request.sessionID, @@ -86,6 +100,26 @@ export default Plugin.define({ } }), ]); + // Holds fail closed the moment OpenCode interrupts the session (user cancel, + // inactivity sweep, supersede). A deferred primary request keeps its + // same-session continuation; a user cancel clears it. The subscription lives + // with the plugin scope. + const eventAbort = new AbortController(); + const events = (async () => { + try { + for await (const event of context.event.subscribe({ signal: eventAbort.signal })) { + if (eventAbort.signal.aborted) break; + if (event.type === "session.execution.interrupted") { + await engine.noteExecutionInterrupted({ + sessionID: event.data.sessionID, + reason: event.data.reason, + }); + } + } + } catch (error) { + if (!eventAbort.signal.aborted) console.error("[opencode-offpeak]", error); + } + })(); const rpc = await context.rpc.register(OFFPEAK_RPC, { control: async (rawInput: unknown): Promise => { const input = rawInput as OffpeakRpcInput; @@ -158,6 +192,8 @@ export default Plugin.define({ await engine.start(); return async () => { + eventAbort.abort(); + await events; await engine.dispose(); await Promise.all(hooks.map((registration) => registration.dispose())); await rpc.dispose(); diff --git a/src/store.ts b/src/store.ts index 2ffc5ed..af116de 100644 --- a/src/store.ts +++ b/src/store.ts @@ -37,9 +37,14 @@ export interface QueueDocument { } /** - * Minimal recovery metadata for an interactive session whose provider request - * failed retryably while pricing was blocked. The session itself is untouched; - * only the identity needed to continue it at the next off-peak window is kept. + * Minimal recovery metadata for a session whose provider request must continue + * at the next allowed window. The session itself is untouched; only the + * identity needed to continue it is kept. + * + * `origin` distinguishes a request that failed retryably during a peak crossing + * ("retry-failure") from a primary interactive request that was deferred before + * its provider attempt ("deferred"). Both continue automatically; a user + * interrupt clears a deferral but not a retry-failure. */ export interface RecoveryEntry { sessionID: string; @@ -47,6 +52,7 @@ export interface RecoveryEntry { modelID: string; directory: string; heldAt: string; + origin?: "retry-failure" | "deferred"; } export interface RecoveryDocument { @@ -219,6 +225,13 @@ function validateRecoveryEntry(value: unknown, index: number): RecoveryEntry { ) { throw new Error(`Stored recovery entry ${index} has invalid fields.`); } + if ( + value.origin !== undefined && + value.origin !== "retry-failure" && + value.origin !== "deferred" + ) { + throw new Error(`Stored recovery entry ${index} has an invalid origin.`); + } return value as unknown as RecoveryEntry; } diff --git a/test/engine.test.mjs b/test/engine.test.mjs index d3ae897..406cd9b 100644 --- a/test/engine.test.mjs +++ b/test/engine.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -1337,3 +1337,318 @@ test("a recovery is skipped when the session already succeeded or never settles" await h.close(); } }); + +test("a primary model.request guard defers the interactive request before transport and releases it at the boundary", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + let releases = 0; + let rejections = 0; + const pending = h.engine + .guardModelRequest({ + sessionID: "model-request-session", + ...selectedModel, + kind: "primary", + transport: "model", + }) + .then( + () => { + releases += 1; + }, + () => { + rejections += 1; + }, + ); + try { + await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 1); + assert.equal(releases, 0); + assert.equal(rejections, 0); + // The primary deferral is persisted so a restart can continue the same session. + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 1); + + // Only the scheduled pricing boundary fires; no config edit and no external wake-up. + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => releases === 1); + assert.equal(rejections, 0); + assert.equal((await h.engine.status(selectedModel)).interactiveWaiting, 0); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(releases, 1); + assert.equal(h.sessions.prompts.length, 0); + assert.equal(h.sessions.createdTasks.length, 0); + await pending; + + // The transport guard still passes after the deferred release. + await h.engine.guardRequest({ + sessionID: "model-request-session", + ...selectedModel, + transport: "http", + }); + } finally { + await h.close(); + } +}); + +test("an auxiliary model.request hold waits without registering a recovery deferral", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + let releases = 0; + const pending = h.engine + .guardModelRequest({ + sessionID: "title-session", + ...selectedModel, + kind: "title", + transport: "model", + }) + .then(() => { + releases += 1; + }); + try { + await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 1); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => releases === 1); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + await pending; + } finally { + await h.close(); + } +}); + +test("a deferred primary request survives a plugin reload and continues the same session once at the boundary", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "reload-deferred-session"; + const pending = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .catch(() => "blocked"); + try { + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 1); + // The plugin reloads: the held request fails closed, the deferral is kept. + await h.engine.dispose(); + assert.equal(await pending, "blocked"); + const storage = h.storage; + const restarted = new OffpeakEngine({ + repository: new QueueRepository(storage), + recoveries: new RecoveryRepository(storage), + location: "test-location", + config: h.config, + sessions: h.sessions, + clock, + }); + // The reloaded turn failed while it was deferred. + h.sessions.outcomes.set(sessionID, "failed"); + h.sessions.workers.set(sessionID, Promise.resolve()); + await restarted.start(); + assert.equal((await restarted.status(selectedModel)).recoveryWaiting, 1); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => h.sessions.prompts.length === 1); + assert.equal(h.sessions.prompts[0].sessionID, sessionID); + assert.equal(h.sessions.prompts[0].text, CONTINUATION); + assert.equal((await restarted.status(selectedModel)).recoveryWaiting, 0); + assert.equal(h.sessions.createdTasks.length, 0); + await clock.advanceTo("2026-10-13T00:00:00Z"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(h.sessions.prompts.length, 1); + await restarted.dispose(); + } finally { + await h.close(); + } +}); + +test("a queue-owned session held at the model.request guard waits in WAITING_OFFPEAK and returns to RUNNING at the boundary", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const now = new Date(clock.now()).toISOString(); + const repository = new QueueRepository(h.storage); + const queue = await repository.load(); + queue.tasks.push({ + id: "task-model-request", + prompt: "queued work", + cwd: "/project", + sessionID: "task-session", + model: selectedModel, + policyID: "deepseek-opencode-go", + state: "RUNNING", + createdAt: now, + lastTransition: now, + retryCount: 0, + }); + await repository.save(queue); + let releases = 0; + const pending = h.engine + .guardModelRequest({ + sessionID: "task-session", + ...selectedModel, + kind: "primary", + transport: "model", + }) + .then(() => { + releases += 1; + }); + try { + await until(async () => (await h.engine.list())[0].state === "WAITING_OFFPEAK"); + assert.equal(releases, 0); + // The queue task is tracked by its own state; no interactive recovery is added. + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + assert.equal((await h.engine.status(selectedModel)).interactiveWaiting, 0); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => releases === 1); + assert.equal((await h.engine.list())[0].state, "RUNNING"); + await pending; + } finally { + await h.close(); + } +}); + +test("a deferral released normally leaves no continuation behind after the next boundary", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "release-clean-session"; + const pending = h.engine.guardModelRequest({ + sessionID, + ...selectedModel, + kind: "primary", + transport: "model", + }); + try { + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 1); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await pending; + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 0); + // The session completed its turn after the release. + h.sessions.outcomes.set(sessionID, "succeeded"); + await clock.advanceTo("2026-10-13T00:00:00Z"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(h.sessions.prompts.length, 0); + } finally { + await h.close(); + } +}); + +test("a user interrupt aborts a deferred hold and clears the deferral", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "user-interrupt-session"; + let rejections = 0; + const pending = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then( + () => "resolved", + (error) => { + rejections += 1; + return error; + }, + ); + try { + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 1); + h.sessions.outcomes.set(sessionID, "interrupted"); + await h.engine.noteExecutionInterrupted({ sessionID, reason: "user" }); + const failure = await pending; + assert.equal(rejections, 1); + assert.ok(failure instanceof PricingBlockedError); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + assert.equal((await h.engine.status(selectedModel)).interactiveWaiting, 0); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(h.sessions.prompts.length, 0); + } finally { + await h.close(); + } +}); + +test("an inactivity interrupt aborts the deferred hold and continues the same session once at the boundary", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "inactivity-session"; + const pending = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then( + () => "resolved", + (error) => error, + ); + try { + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 1); + h.sessions.outcomes.set(sessionID, "interrupted"); + h.sessions.workers.set(sessionID, Promise.resolve()); + await h.engine.noteExecutionInterrupted({ sessionID, reason: "inactivity" }); + const failure = await pending; + assert.ok(failure instanceof PricingBlockedError); + // The deferral survives the interrupt so no manual retry is needed. + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 1); + assert.equal((await h.engine.status(selectedModel)).interactiveWaiting, 0); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => h.sessions.prompts.length === 1); + assert.equal(h.sessions.prompts[0].sessionID, sessionID); + assert.equal(h.sessions.prompts[0].text, CONTINUATION); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + // No blind replay of the interrupted provider request, and no new session. + assert.equal(h.sessions.createdTasks.length, 0); + } finally { + await h.close(); + } +}); + +test("a shutdown interrupt leaves the deferral in place for restart recovery", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "shutdown-session"; + const pending = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then( + () => "resolved", + (error) => error, + ); + try { + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 1); + await h.engine.noteExecutionInterrupted({ sessionID, reason: "shutdown" }); + assert.ok((await pending) instanceof PricingBlockedError); + h.sessions.outcomes.set(sessionID, "failed"); + h.sessions.workers.set(sessionID, Promise.resolve()); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => h.sessions.prompts.length === 1); + assert.equal(h.sessions.prompts[0].sessionID, sessionID); + assert.equal(h.sessions.prompts[0].text, CONTINUATION); + } finally { + await h.close(); + } +}); + +test("a stored copy of the built-in policy adopts the built-in holiday calendar", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-offpeak-policy-adopt-")); + const configPath = path.join(root, "offpeak.json"); + const builtin = defaultConfig().policies[0]; + const legacy = structuredClone(builtin); + delete legacy.holidays; + legacy.verifiedAt = "2026-10-09"; + const customized = { + ...structuredClone(legacy), + id: "custom-window", + blocked: [{ days: [1], start: "22:00", end: "23:00" }], + }; + const explicit = { ...structuredClone(legacy), id: "explicit-holidays", holidays: [] }; + try { + await writeFile( + configPath, + JSON.stringify({ + schemaVersion: 1, + enabled: true, + mode: "auto", + displayTimeZone: "UTC", + policies: [legacy, customized, explicit], + }), + ); + const config = new ConfigRepository({ configPath }); + const read = await config.read(); + assert.equal(read.error, undefined); + const [adopted, kept, keptExplicit] = read.config.policies; + assert.equal(adopted.id, builtin.id); + assert.deepEqual(adopted.holidays, builtin.holidays); + // A customized window is preserved exactly and gains no holiday data. + assert.deepEqual(kept.blocked, customized.blocked); + assert.equal(kept.holidays, undefined); + // An explicit holiday list (even empty) is never overwritten. + assert.deepEqual(keptExplicit.holidays, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/plugin-entries.test.mjs b/test/plugin-entries.test.mjs index 2e13b4f..1bec2de 100644 --- a/test/plugin-entries.test.mjs +++ b/test/plugin-entries.test.mjs @@ -32,10 +32,46 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp const toasts = []; let rpcDefinition; let rpcHandlers; + const eventQueue = []; + const eventWaiters = []; + const pushEvent = (event) => { + const waiter = eventWaiters.shift(); + if (waiter) waiter({ value: event, done: false }); + else eventQueue.push(event); + }; const serverContext = { options: { configPath }, app: { name: "opencode", version: "2.0.26", channel: "latest" }, location: { directory: "/fixture-project" }, + event: { + subscribe(requestOptions) { + const signal = requestOptions?.signal; + return { + [Symbol.asyncIterator]() { + return { + next() { + if (signal?.aborted) { + return Promise.resolve({ value: undefined, done: true }); + } + const queued = eventQueue.shift(); + if (queued) return Promise.resolve({ value: queued, done: false }); + return new Promise((resolve) => { + const finish = () => resolve({ value: undefined, done: true }); + signal?.addEventListener("abort", finish, { once: true }); + eventWaiters.push((result) => { + signal?.removeEventListener("abort", finish); + resolve(result); + }); + }); + }, + return() { + return Promise.resolve({ value: undefined, done: true }); + }, + }; + }, + }; + }, + }, storage: { async get(key) { return structuredClone(values.get(key)); @@ -91,6 +127,7 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp ); try { assert.equal(rpcDefinition.id, "opencode-offpeak"); + assert.ok(hooks.has("model.request")); assert.ok(hooks.has("http.request")); assert.ok(hooks.has("experimental.ws.handshake")); assert.ok(hooks.has("experimental.ws.send")); @@ -122,6 +159,68 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp // boundary instead of discarding it. await assertStillPending(heldOutcome, 60); + // A primary pre-model request is deferred before any provider attempt exists and + // persists a deferral entry so a reload can continue the same session. + const modelHeldOutcome = hooks + .get("model.request")({ + sessionID: "interactive-model-request", + kind: "primary", + model: { providerID: "opencode-go", id: "deepseek-v4.1-flash" }, + }) + .then( + () => "resolved", + (error) => error, + ); + await assertStillPending(modelHeldOutcome, 60); + const deferredStatus = await rpcHandlers.control({ + action: "status", + providerID: "opencode-go", + modelID: "deepseek-v4.1-flash", + }); + // One recovery from the retry veto above plus this session's deferral. + assert.equal(deferredStatus.status.recoveryWaiting, 2); + + // A user interrupt clears the deferral and aborts the hold without continuing. + pushEvent({ + type: "session.execution.interrupted", + data: { sessionID: "interactive-model-request", reason: "user" }, + }); + const userAborted = await modelHeldOutcome; + assert.ok(userAborted instanceof PricingBlockedError); + assert.match(userAborted.message, /interrupted/); + const afterUserInterrupt = await rpcHandlers.control({ + action: "status", + providerID: "opencode-go", + modelID: "deepseek-v4.1-flash", + }); + assert.equal(afterUserInterrupt.status.recoveryWaiting, 1); + + // An inactivity interrupt aborts the hold but keeps the deferral so the same + // session continues at the next allowed window. + const inactivityHeld = hooks + .get("model.request")({ + sessionID: "interactive-inactivity", + kind: "primary", + model: { providerID: "opencode-go", id: "deepseek-v4.1-flash" }, + }) + .then( + () => "resolved", + (error) => error, + ); + await assertStillPending(inactivityHeld, 60); + pushEvent({ + type: "session.execution.interrupted", + data: { sessionID: "interactive-inactivity", reason: "inactivity" }, + }); + const inactivityAborted = await inactivityHeld; + assert.ok(inactivityAborted instanceof PricingBlockedError); + const afterInactivity = await rpcHandlers.control({ + action: "status", + providerID: "opencode-go", + modelID: "deepseek-v4.1-flash", + }); + assert.equal(afterInactivity.status.recoveryWaiting, 2); + const off = await rpcHandlers.control({ action: "off" }); assert.match(off.message, /Peak pricing is still blocked/); // OFF never releases a held request and is not a price override. @@ -153,7 +252,7 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp assert.equal(status.status.queued, 1); assert.equal(status.status.requestsAllowed, false); assert.equal(status.status.interactiveWaiting, 1); - assert.equal(status.status.recoveryWaiting, 1); + assert.equal(status.status.recoveryWaiting, 2); let layer; const tuiContext = { diff --git a/test/policy.test.mjs b/test/policy.test.mjs index c948298..3b35c80 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -109,3 +109,154 @@ test("invalid timezone and schedule data fail closed", () => { "UNKNOWN", ); }); + +const dateRange = (start, end) => { + const dates = []; + for ( + let day = Date.parse(`${start}T00:00:00Z`); + day <= Date.parse(`${end}T00:00:00Z`); + day += 86_400_000 + ) { + dates.push(new Date(day).toISOString().slice(0, 10)); + } + return dates; +}; + +test("Chinese public holidays override weekday peak windows", () => { + // Same hour, same nominal window: normal Monday, weekend days, and a statutory holiday. + assert.equal(state("2026-10-12T02:00:00Z").state, "PEAK"); + assert.equal(state("2026-10-10T02:00:00Z").state, "OFF_PEAK"); + assert.equal(state("2026-10-11T02:00:00Z").state, "OFF_PEAK"); + assert.equal(state("2026-10-01T02:00:00Z").state, "OFF_PEAK"); + assert.equal(state("2026-10-01T08:00:00Z").state, "OFF_PEAK"); +}); + +test("every official 2026 holiday period is off-peak all day", () => { + const periods = [ + ["2026-01-01", "2026-01-03"], + ["2026-02-15", "2026-02-23"], + ["2026-04-04", "2026-04-06"], + ["2026-05-01", "2026-05-05"], + ["2026-06-19", "2026-06-21"], + ["2026-09-25", "2026-09-27"], + ["2026-10-01", "2026-10-07"], + ]; + for (const [start, end] of periods) { + for (const date of dateRange(start, end)) { + // Both nominal weekday PEAK windows must be off-peak on every holiday date. + assert.equal(state(`${date}T02:00:00Z`).state, "OFF_PEAK", date); + assert.equal(state(`${date}T08:00:00Z`).state, "OFF_PEAK", date); + } + } +}); + +test("official weekend make-up workdays stay off-peak", () => { + for (const date of [ + "2026-01-04", + "2026-02-14", + "2026-02-28", + "2026-05-09", + "2026-09-20", + "2026-10-10", + ]) { + assert.equal(state(`${date}T02:00:00Z`).state, "OFF_PEAK", date); + assert.equal(state(`${date}T08:00:00Z`).state, "OFF_PEAK", date); + } +}); + +test("the days immediately before and after a holiday keep the normal weekly schedule", () => { + assert.equal(state("2026-04-03T02:00:00Z").state, "PEAK"); + assert.equal(state("2026-04-07T02:00:00Z").state, "PEAK"); + assert.equal(state("2026-09-24T02:00:00Z").state, "PEAK"); + assert.equal(state("2026-09-28T02:00:00Z").state, "PEAK"); + assert.equal(state("2026-09-30T02:00:00Z").state, "PEAK"); + assert.equal(state("2026-10-08T02:00:00Z").state, "PEAK"); +}); + +test("nextTransition skips holiday peak windows and points at the next real peak", () => { + assert.equal(state("2026-09-30T12:00:00Z").nextTransition, at("2026-10-08T01:00:00Z")); + assert.equal(state("2026-10-05T02:00:00Z").nextTransition, at("2026-10-08T01:00:00Z")); + assert.equal(state("2026-10-07T23:59:00Z").nextTransition, at("2026-10-08T01:00:00Z")); + assert.equal(state("2026-01-01T02:00:00Z").nextTransition, at("2026-01-05T01:00:00Z")); + // Inside a holiday peak hour the state is off-peak, and the transition is the + // next real peak after the holiday, not a boundary inside it. + assert.equal(state("2026-05-04T02:00:00Z").state, "OFF_PEAK"); + assert.equal(state("2026-05-04T02:00:00Z").nextTransition, at("2026-05-06T01:00:00Z")); +}); + +test("holiday dates follow the calendar time zone, not the host or UTC date", () => { + const policy = { + ...DEEPSEEK_OPENCODE_GO, + id: "holiday-time-zone-test", + timeZone: "UTC", + blocked: [{ days: [0, 1, 2, 3, 4, 5, 6], start: "16:00", end: "17:00" }], + holidays: [ + { + year: 2026, + timeZone: "Asia/Shanghai", + dates: ["2026-10-02"], + source: "fixture", + }, + ], + }; + const evaluate = (iso) => evaluatePricing([policy], provider, model, at(iso)); + // 2026-10-02 starts at 2026-10-01T16:00:00Z in Asia/Shanghai. + assert.equal(evaluate("2026-10-01T16:00:00Z").state, "OFF_PEAK"); + assert.equal(evaluate("2026-10-01T16:30:00Z").state, "OFF_PEAK"); + // The same UTC hour on the UTC calendar date is not the listed Shanghai date. + assert.equal(evaluate("2026-10-02T16:00:00Z").state, "PEAK"); + assert.equal(evaluate("2026-10-02T16:30:00Z").state, "PEAK"); + assert.equal(state("2026-10-01T07:00:00Z").state, "OFF_PEAK"); +}); + +test("years without a holiday calendar keep the deterministic weekly schedule", () => { + const holidays = DEEPSEEK_OPENCODE_GO.holidays ?? []; + assert.ok(holidays.every((calendar) => calendar.year !== 2027)); + // A Monday in 2027 inside a nominal peak window is still peak; weekends stay off-peak. + assert.equal(state("2027-01-04T02:00:00Z").state, "PEAK"); + assert.equal(state("2027-01-02T02:00:00Z").state, "OFF_PEAK"); +}); + +test("the built-in China holiday calendar carries its authority metadata", () => { + const calendar = DEEPSEEK_OPENCODE_GO.holidays?.find((item) => item.year === 2026); + assert.ok(calendar); + assert.equal(calendar.timeZone, "Asia/Shanghai"); + assert.match(calendar.source, /^https:\/\/www\.gov\.cn\//); + assert.equal(typeof calendar.verifiedAt, "string"); + assert.equal(typeof calendar.description, "string"); + assert.equal(new Set(calendar.dates).size, calendar.dates.length); +}); + +test("invalid holiday calendar data fails closed", () => { + const base = { + ...DEEPSEEK_OPENCODE_GO, + id: "holiday-validation", + holidays: [DEEPSEEK_OPENCODE_GO.holidays[0]], + }; + const cases = [ + { ...base, holidays: [{ ...base.holidays[0], dates: ["2026-13-01"] }] }, + { ...base, holidays: [{ ...base.holidays[0], dates: ["2026-02-30"] }] }, + { ...base, holidays: [{ ...base.holidays[0], dates: ["2027-01-01"] }] }, + { ...base, holidays: [{ ...base.holidays[0], dates: ["2026-01-01", "2026-01-01"] }] }, + { ...base, holidays: [{ ...base.holidays[0], source: "" }] }, + { ...base, holidays: [{ ...base.holidays[0], timeZone: "Mars/Olympus_Mons" }] }, + { ...base, holidays: [{ ...base.holidays[0], year: "2026" }] }, + ]; + for (const policy of cases) { + const decision = evaluatePricing([policy], provider, model, at("2026-10-12T02:00:00Z")); + assert.equal(decision.state, "UNKNOWN", JSON.stringify(policy.holidays)); + assert.equal(decision.allowed, false); + } +}); + +test("an empty holiday list keeps the weekly schedule untouched", () => { + const policy = { ...DEEPSEEK_OPENCODE_GO, id: "no-holidays", holidays: [] }; + assert.equal( + evaluatePricing([policy], provider, model, at("2026-10-01T02:00:00Z")).state, + "PEAK", + ); + assert.equal( + evaluatePricing([policy], provider, model, at("2026-10-10T02:00:00Z")).state, + "OFF_PEAK", + ); +});