diff --git a/CHANGELOG.md b/CHANGELOG.md index 90675e150..a621be87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,10 @@ breaking changes may land in a minor release. place (#865). - Add the `hooks.events-incomplete` validate warning: a profile hook event whose relay is not registered, with the `init` that registers it or the CLI version it needs. +- Add opt-in `[limits] usage_limit_action = "relaunch"`: a session that ends on a usage + limit relaunches the story at once without charging an attempt, up to + `max_usage_relaunches` per story (default 3), then pauses as before. The relaunched + session gets `BMAD_LOOP_USAGE_RELAUNCH` for account-rotation wrappers (#959). ### Changed @@ -207,6 +211,8 @@ breaking changes may land in a minor release. - Name a displaced psmux registry that belongs to another project (or this one before a rename) as such in `cleanup` and the TUI, instead of calling its sessions `not migrated` (#906). +- Pause a Claude session uncharged, instead of charging a dev attempt for a crash, when + its window dies after a usage-limit or account StopFailure or during a quota wait (#959). ### Security diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 4acba2b2e..9aa5a1327 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -108,10 +108,12 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches - When a Claude Code turn ends on an API error (its `StopFailure` hook), the adapter acts at once and never counts it as a completion: a transient error gets the stall nudge right away and is charged if never recovered, and an account error (billing, authentication, an account hold) pauses the run uncharged with a reason naming the error. - `validate --probes` launches each role's CLI once with no prompt and fails when its first screen shows a startup dialog that would park an unattended session. - While the pane shows the CLI waiting out a usage-limit reset, the session timeout and stall nudge are held for at most `[limits] quota_wait_max_min` (default 300; 0 = off) minutes; past that the session pauses with a usage-limit reason. + - With `[limits] usage_limit_action = "relaunch"`, a dev, fix or review session that ends on a usage limit or a Claude Code account error is relaunched at once without charging an attempt or review cycle, at most `[limits] max_usage_relaunches` (default 3) times per story; past that it pauses uncharged as in the default `"pause"` mode. + - In relaunch mode the session ends at the limit instead of waiting for its reset, a Claude Code `rate_limit` error naming no known limit counts as one, and the relaunched session gets `BMAD_LOOP_USAGE_RELAUNCH=` so a wrapper can switch accounts. - Before an attempt is reset, its commits go to an `attempt-preserve/-` branch and uncommitted work to a `refs/attempt-preserve-dirty/*` snapshot. -- **Config:** `[limits] max_dev_attempts`, `[limits] max_review_cycles`, `[limits] session_timeout_min`, `[limits] dev_stall_grace_s`, `[limits] dev_stall_nudges`, `[limits] dev_stall_nudges_cap`, `[limits] quota_wait_max_min`, `[scm] preserve_keep` +- **Config:** `[limits] max_dev_attempts`, `[limits] max_review_cycles`, `[limits] session_timeout_min`, `[limits] dev_stall_grace_s`, `[limits] dev_stall_nudges`, `[limits] dev_stall_nudges_cap`, `[limits] quota_wait_max_min`, `[limits] usage_limit_action`, `[limits] max_usage_relaunches`, `[scm] preserve_keep` - **Tested by:** `tests/test_escalation.py`, `tests/test_engine.py`, `tests/test_generic_tmux.py`, `tests/test_panematch.py`, `tests/test_sweep.py`, `tests/test_verify.py`, `tests/test_recovery_flow.py`, `tests/test_stories_e2e.py` -- **Docs:** [Preserved attempts](escalations-guide.md#preserved-attempts), [StopFailure signals](adapter-authoring-guide.md#stopfailure-signals), [[limits] reference](reference/configuration.md#limits), [[scm] reference](reference/configuration.md#scm) +- **Docs:** [Preserved attempts](escalations-guide.md#preserved-attempts), [Usage-limit pauses](escalations-guide.md#usage-limit-pauses), [Account rotation](plugin-authoring-guide.md#account-rotation), [StopFailure signals](adapter-authoring-guide.md#stopfailure-signals), [[limits] reference](reference/configuration.md#limits), [[scm] reference](reference/configuration.md#scm) ### Plateau-defer @@ -175,6 +177,7 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches - A pattern match is overruled when the session's tracked token usage is nonzero; that session is charged like any failure. - A session that times out, stalls or crashes after a live usage-limit sighting (its profile's `usage_limit_patterns` or `usage_wait_patterns` on the pane, or an opencode 2.x quota error) pauses with a `usage limit:` reason and is never charged; the reason names the reset time when the line gives one. - A Claude Code `StopFailure` reporting `rate_limit` is matched by its message against the same usage lists: a blocking limit pauses as a usage limit, and a waiting limit can start the quota wait without a pane capture. + - A session whose window dies after a Claude Code usage-limit or account `StopFailure`, or during a quota wait (including at its admission or cap), ends `crashed` but pauses uncharged like the live case; a crash after an unclassified error is still charged. - `bmad-loop resolve --at-reset` waits until a usage-limit pause's reset plus `--margin-min` (default 2), then re-arms and resumes; credit exhaustion and an unknown or too-distant reset are refused. - **Config:** `[environment] probes`, `[environment] probe_timeout_s`, `[verify] env_fault_rc`, `[limits] quota_wait_max_min`; profile keys `env_fault_patterns`, `usage_limit_patterns`, `usage_wait_patterns` - **Tested by:** `tests/test_engine.py`, `tests/test_cli.py`, `tests/test_escalation.py`, `tests/test_env_fault_patterns.py`, `tests/test_env_fault.py`, `tests/test_generic_tmux.py`, `tests/test_opencode_http.py`, `tests/test_usagereset.py` diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 24445cc1b..513bc689f 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -532,13 +532,44 @@ The generic adapter acts on it when it arrives, by action: offers none, so a backend that cannot capture its pane can still admit the wait. No match leaves `stop-failure-rate-limit-unmatched` and the usual stall timing. A match that blows the per-pattern timeout leaves `stop-failure-probe-failed` and counts as no - match. + match. In usage-relaunch mode (see below) a wait match and an unmatched value end the + session at once instead. - **`ignore`**: nothing beyond the crumb. Every event leaves a `stop-failure` lifecycle crumb (error, action, details, a message excerpt); an unmapped value also leaves `stop-failure-unmapped` and keeps the usual stall timing. A later `Stop` clears the latch. `SessionResult.stop_failure` carries the last -uncleared error, as does the heartbeat and the `session-end` journal entry. +uncleared error, as does the heartbeat and the `session-end` journal entry, and +`SessionResult.stop_failure_action` its mapped action. + +**A window that dies after the event.** An operator's own `StopFailure` hook may kill the +window, for example to switch accounts. The session then ends `crashed`, but an `account` +error, or a `rate_limit` that matched a usage list, keeps its classification: the result +is labeled `parked` (account) or `usage_limited` (blocked or wait class), and the run gets +the same uncharged pause as the live exit. The adapter leaves a `crash-relabeled` +lifecycle crumb (`kind`, `error`). A `Stop` clears the classification, and so does a +transcript write after the event, since the CLI recovered by itself. A crash whose result +file exists is completed as before and never relabeled. An unmatched `rate_limit` stays an +unlabeled, charged crash outside usage-relaunch mode. + +**Usage-relaunch mode.** With `[limits] usage_limit_action = "relaunch"`, primary dev and +review sessions are launched with two flags on `SessionSpec`: `usage_relaunch_mode`, and +`usage_relaunch_left` while the story has relaunches left under +`limits.max_usage_relaunches`. Then: + +- a `rate_limit` that matches neither usage list counts as a wait-class usage limit. The + session ends `stalled` + `parked` + `usage_limited` at once, with the evidence + `StopFailure(): ` and no reset. The + `stop-failure-rate-limit-unmatched` crumb carries `relaunch_mode=True`. A window that + dies after it is relabeled `usage_limited` the same way (`crash-relabeled`, + `kind="unmatched"`). This holds at the cap too, where the engine pauses uncharged at + once instead of after a stall grace. +- with relaunches left, a `rate_limit` that matched `usage_wait_patterns` ends the session + at the event instead of waiting for the quota wait (`quota-wait-skipped`, + `reason="usage-relaunch"`). + +The engine relaunches such a session without charging an attempt (see +[Usage-limit pauses](escalations-guide.md#usage-limit-pauses)). The shipped `claude` profile maps the documented values: `overloaded`, `server_error`, `unknown`, `max_output_tokens` and `invalid_request` (a context-shape failure that a fresh @@ -691,8 +722,9 @@ matched evidence. **When the session timeout fires**, one capture against both l `usage_limited` on the `timeout` result. Neither gate takes a usage match from a session that is visibly working: a transcript written within the last 90 s (as of the last heartbeat) or an unanswered tool call means the line is left over from a limit it -recovered from, and the stall or timeout is an ordinary one. A dead window is never -usage-limited. +recovered from, and the stall or timeout is an ordinary one. A dead window is +usage-limited only when it died during a quota wait or after a usage `StopFailure` (see +[StopFailure signals](#stopfailure-signals)). A `usage_limited` result is classified as an environment fault with no token-usage overrule and no tail scan, and pauses with a `usage limit:` reason (see @@ -727,6 +759,15 @@ line whose parsed reset lies beyond the remaining cap is not waited on: the sess the same way at once (`quota-wait-skipped`, `reason="reset-beyond-cap"`). An unresolved reset is admitted, and the cap bounds it. +A window that dies during the wait ends +`crashed` with the wait's evidence and reset as a wait-class `usage_limited` +(`crash-relabeled`, `kind="quota-wait"`). + +In usage-relaunch mode with relaunches left (`SessionSpec.usage_relaunch_left`), no wait +is admitted: where it would open, the session ends `stalled` + `parked` + `usage_limited` +at once (`quota-wait-skipped`, `reason="usage-relaunch"`), so the engine can relaunch the +story uncharged. At the cap the wait admits as usual. + The `opencode-http` adapter runs its own wait loop with no pane, so neither the usage gate nor the quota wait applies there. diff --git a/docs/escalations-guide.md b/docs/escalations-guide.md index 52a3b2473..c239e3899 100644 --- a/docs/escalations-guide.md +++ b/docs/escalations-guide.md @@ -279,7 +279,7 @@ A session the adapter saw blocked by a coding-CLI usage limit (out of credits, o usage limit: dev session stalled (; resets