> Added from headless-session-ops/SKILL.md (triggers `quota wall` / `配额墙`, > Post-launch lifecycle section, pitfall #17, References) — new runbook > 2026-08-29, ticket [org-internal #3669] (evidence instance [org-internal #3627]). Companion runbooks: > `lifecycle-ops.md` (hung rescue, fact-baseline structure) and > `completion-report.md` §"Orchestrator side" (the quota self-report clause); > §Prevention is this file's own closing section. ## Quota wall — worker killed mid-task by a provider usage limit A **quota wall** is when a headless worker's turn is killed by a terminal provider-quota error (usage limit / 429 family) mid-task. From the issue side it looks EXACTLY like silent completion: no completion-report, no `status=blocked` report, the ticket just stops moving. The stalled window equals the orchestrator's polling interval, and without this runbook the recovery was improvised. **Evidence instance ([org-internal #3627], 2026-08-29)** — the shapes below are field-verified: |时刻 (+08)| 事件 | |---|---| | 08-29 13:18 | review-code Round-1 synthesis (FAIL) posted on [org-internal #3627]; worker session `ses_fb46e705bffe9kYbSoHayoJ1OQ` (builder, opencode-go model, branch `workflow/session/3627-wedge-promptasync`) entered its revision loop | | ~13:18–16:00 | worker hit the provider's `weekly usage limit reached` mid-revision — turn killed, **no** completion-report, **no** blocked report; issue side silent | | detection | orchestrator noticed the `/session` state stall, then confirmed via the quota error in the session log (message tail carries the ⚠️ usage-limit part, [org-internal #2912]) | | recovery | re-wake via `prompt_async` on a known-good provider (`zhipuai-coding-plan`/glm-5.3 — since renamed `zai-coding-plan`) **plus a fact-baseline injection** (what happened / what is done / where to resume) | | 16:26 / 16:51 | Round-2 synthesis PASS; `status=done pr=[org-internal #3675]` report received | ## Detection — three signals, in this order Set the usual variables first: ```sh BASE=http://127.0.0.1:4096 # dev backend (:4180 prod) DIR=/data/octopus SID= ``` **Signal 1 — tail probe (primary; survives restarts, lives in the DB).** Since [org-internal #2912]/[org-internal #3190] a quota-killed turn persists an assistant message with `finish=error` plus a human-readable ⚠️ text part whose metadata names the quota family. Probe the TAIL (`limit=1`, never the no-`limit` full transcript): ```sh curl -s "$BASE/session/$SID/message?directory=$DIR&limit=1" | jq ' {role: .[0].info.role, finish: .[0].info.finish, error_parts: [.[0].parts[]? | select(.type=="text" and .metadata?.error == true) | {text: .text[0:160], metadata: .metadata}]}' ``` Quota-wall verdict: `role=assistant` AND `finish=error` AND an error part with `metadata.reason` in `{account_rate_limit, free_tier_limit, account_usage_limit, rate_limit}` (metadata also carries `provider`). The ⚠️ text is one of — all strings observed in this repo's runtime (`session/retry.ts`): - `` `weekly usage limit` reached. It will reset in … `` / `Go limit reached` (GoUsageLimitError — the [org-internal #3627] shape; `weekly` is the body's `limitName`) - `Free limit reached` (FreeUsageLimitError) - `Account-level usage limit — switching API keys does not reset it. You can continue after HH:mm.` ([org-internal #3407], zhipu/zai code 1308 — account-level) - `Provider rate limited (HTTP 429)` (generic 429) - `Generation failed — …` with a 402 `insufficient_quota` body (balance, not window — recovery is top-up, not provider switch) **Signal 2 — state (two shapes; use the lifecycle-ops dual-sample rule).** ```sh curl -s "$BASE/session?directory=$DIR" | jq -r --arg s "$SID" \ '.[] | select(.id==$s) | .state' ``` - **idle** + Signal-1 tail → the turn already died on the quota error. No abort needed; go straight to Recovery. - **generating** with `progress`/newest-message-id frozen across two polls 30–60 s apart → the 429 retry spin is still running (it retries with backoff until the schedule ends) OR the stream is hung — treat exactly like the hung shape in `lifecycle-ops.md`: abort FIRST, then re-wake. A single snapshot is never an abort basis ([org-internal #3215]). **Signal 3 — provider-level confirmation ([org-internal #2911] quota markers).** `GET /provider` overlays active usage-limit cooldowns, so the dead provider is visible without reading logs: ```sh curl -s "$BASE/provider?directory=$DIR" | jq '[.all[] | select(.quota) | {id, quota}]' # quota: {markedAt: , resetAt?: } — resetAt absent = reset unknown date -d @$(($(curl -s "$BASE/provider?directory=$DIR" | jq '[.all[].quota.resetAt // 0] | max') / 1000)) # earliest full-reset wall clock ``` **Log grep (fallback; the dev log is restart-truncated).** Patterns actually seen in the wild — [org-internal #3627] produced the first: ```sh grep -E "usage limit reached|Go limit reached|Free limit reached|Account-level usage limit|GoUsageLimitError|FreeUsageLimitError|rate limited \(HTTP 429\)|insufficient_quota" \ /tmp/octopus-backend.log | tail -20 # the pool-failover WARN precedes the wall when same-name pools exist: # "Usage limit reached on — retrying on " ``` The wall is only terminal after failover has exhausted the same-name pools ([org-internal #2911] failover); the `retrying … on …` WARNs above tell you the pool was already draining before it died. ## Recovery — abort (only if needed), re-wake on a known-good provider, inject facts **Step 0 — classify the state (Signal 2).** `idle` → skip abort. `generating` + frozen across dual samples → abort first, exactly per `lifecycle-ops.md` (queued prompts are never consumed by a dead stream): ```sh curl -s -X POST "$BASE/session/$SID/abort?directory=$DIR" -o /dev/null -w "%{http_code}\n" # → 200 curl -s "$BASE/session?directory=$DIR" | jq -r --arg s "$SID" '.[] | select(.id==$s) | .state' # → idle ``` **Step 1 — inventory surviving work.** The killed turn's filesystem writes survive; list them so the worker re-validates instead of redoing: ```sh git -C status --short git -C log --oneline -3 ``` **Step 2 — pick a known-good provider/model.** The re-wake payload MUST carry an explicit `model` (SKILL.md Iron Law), and after a quota wall the CURRENT provider is by definition NOT known-good. Pick a provider that is connected AND not quota-marked: ```sh curl -s "$BASE/provider?directory=$DIR" | jq -r \ '.connected as $c | [.all[] | select(.id as $id | $c | index($id)) | select(.quota | not) | .id] | join("\n")' # then pick a modelID you know works on that provider (e.g. the orchestrator's # own current model — it is generating by definition). ``` ([org-internal #3627]: opencode-go dead → re-woken on zhipuai-coding-plan/glm-5.3, since renamed `zai-coding-plan`. Do NOT "wait for reset" as the default — weekly windows mean days.) **Empty list — every connected provider is quota-marked.** The loop needs an explicit exit; never re-wake into a wall you can see. Find the earliest reset among the marked connected providers: ```sh curl -s "$BASE/provider?directory=$DIR" | jq -r \ '.connected as $c | [.all[] | select(.id as $id | $c | index($id)) | select(.quota)] | min_by(.quota.resetAt // 9007199254740991) # resetAt absent → sorts last | "\(.id): markedAt=\(.quota.markedAt) resetAt=\(.quota.resetAt // "unknown")"' # resetAt is epoch-ms → wall clock: date -d @$(( / 1000)) ``` - Known `resetAt` within reach → suspend the re-wake (leave the worker down) and re-run this Step after the reset; weekly windows mean days — schedule the retry, do not poll in a tight loop. - `resetAt` unknown, or the earliest window is unacceptable (402 `insufficient_quota` is balance — reset means top-up, not time) → **escalate to the human orchestrator**: report every marked provider's `id`/`markedAt`/`resetAt` and stop. Top-up or onboarding a new provider is a human decision, not another re-wake. **Step 3 — re-wake with a fact-baseline prompt.** Build the payload from a file (`jq --rawfile`, SKILL.md pitfall #7), same wake shape as `lifecycle-ops.md` but with the quota facts. The template also restates the completion-report protocol ([org-internal #2374]) so the recovery is self-contained: it assumes the original seed still contains the protocol block — if you are unsure it does (e.g. the worker was dispatched before [org-internal #3669] added the quota clause), the template's own block below carries it; fill ``/``/`` with the orchestrator session's values: ```sh cat > wake.txt <<'EOF' ## 事实基线(配额墙恢复) 你上一回合被供应商配额错误杀死,世界观停留在被杀时刻。先读以下事实再行动: 1. 发生了什么: 你的回合因 配额耗尽终止(usage limit, 预计重置 );本消息由已知良好供应商重新唤醒,非你自行恢复。 2. 已做什么:<已完成步骤/commit/PR 状态>;worktree 未提交变更: ——先 re-validate 这些文件,勿重做已完成工作。 3. 从哪继续:<一件具体的下一步任务>。 4. 证据规则:逐条到引用来源(issue 评论 / PR / 工件)核实后再行动, 勿仅凭本基线行动。 ## 完成回报协议(重申 [org-internal #2374]/[org-internal #3669];不确定原始 seed 是否含协议块时以本节为准) 完成、阻塞或移交时,必须回报编排会话(你的唤醒者): POST /session//prompt_async?directory= body: {"agent":"builder","model":{...},"parts":[{"type":"text","text":""}]} 单行格式:[#] [] status=done|blocked|handoff pr=# summary=<一行> 约束:若再遇配额/用量上限错误,立即回报 status=blocked reason=quota-exhausted summary=(pr=# 有 PR 时带上) 后停止,勿自行换模型重试;回报失败 → 源 issue 评论兜底。 EOF jq -n --rawfile p wake.txt '{agent:"builder", model:{providerID:"",modelID:""}, parts:[{type:"text",text:$p}]}' \ | curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \ -H 'content-type: application/json' -d @- -o /dev/null -w "%{http_code}\n" # → 204 ``` Then poll the tail (`limit=1`) until an assistant message appears, and keep expecting the completion-report per protocol — the re-woken worker owes you `status=done|blocked` like any other (the restated block in the template makes this hold even when the original seed predates [org-internal #3669]). ## Prevention — before dispatch, and in the seed **Pre-dispatch provider check (30 seconds, catches most walls):** ```sh # 1) no active quota markers on the dispatch provider (else pick another) curl -s "$BASE/provider?directory=$DIR" | jq '[.all[] | select(.quota) | .id]' # 2) dispatch provider is connected AND ≥1 other connected provider exists as fallback curl -s "$BASE/provider?directory=$DIR" | jq '{connected, fallbacks: (.connected | length > 1)}' ``` If the intended provider is already quota-marked, dispatch on a different one — do not launch into a wall you can see coming. **Quota self-report clause (turns a silent death into a harvestable blocked).** The canonical clause text lives in `completion-report.md` §"Orchestrator side" (配额自报, [org-internal #3669]) — append it VERBATIM to the seed prompt's completion-report block. Single source of truth: do not fork or restate the clause here; its blocked payload already follows the one-line key=value spec (`status=blocked reason=quota-exhausted summary= pr=#N`, completion-report.md `` format). With the clause in the seed, a worker that hits the wall reports `blocked` instead of dying silently — the orchestrator harvests the report and runs the Recovery section directly. The clause is also why the re-wake prompt's constraint (Step 3) repeats it: the re-woken worker must know the rule still holds on the new provider.