Files

12 KiB
Raw Permalink Blame History

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:1816: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:

BASE=http://127.0.0.1:4096   # dev backend (:4180 prod)
DIR=/data/octopus
SID=<worker session id>

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):

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 <id> 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).

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 3060 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:

curl -s "$BASE/provider?directory=$DIR" | jq '[.all[] | select(.quota) | {id, quota}]'
# quota: {markedAt: <epoch-ms>, resetAt?: <epoch-ms>} — 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:

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 <provider> — retrying <model> on <provider>"

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):

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:

git -C <worktree> status --short
git -C <worktree> 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:

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:

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 @$((<resetAt> / 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 <BASE>/<LAUNCHER_SID>/<DIR> with the orchestrator session's values:

cat > wake.txt <<'EOF'
## 事实基线(配额墙恢复)
你上一回合被供应商配额错误杀死,世界观停留在被杀时刻。先读以下事实再行动:
1. 发生了什么:<HH:mm> 你的回合因 <provider> 配额耗尽终止(usage limit
   预计重置 <HH:mm 或 未知>);本消息由已知良好供应商重新唤醒,非你自行恢复。
2. 已做什么:<已完成步骤/commit/PR 状态>worktree 未提交变更:
   <git status --short 摘要>——先 re-validate 这些文件,勿重做已完成工作。
3. 从哪继续:<一件具体的下一步任务>。
4. 证据规则:逐条到引用来源(issue 评论 / PR / 工件)核实后再行动,
   勿仅凭本基线行动。
## 完成回报协议(重申 [org-internal #2374]/[org-internal #3669];不确定原始 seed 是否含协议块时以本节为准)
完成、阻塞或移交时,必须回报编排会话(你的唤醒者):
  POST <BASE>/session/<LAUNCHER_SID>/prompt_async?directory=<DIR>
  body: {"agent":"builder","model":{...},"parts":[{"type":"text","text":"<report>"}]}
<report> 单行格式:[#<ticket>] [<node-or-slug>] status=done|blocked|handoff pr=#<N> summary=<一行>
约束:若再遇配额/用量上限错误,立即回报 status=blocked
reason=quota-exhausted summary=<provider 与重置提示>pr=#<N> 有 PR 时带上)
后停止,勿自行换模型重试;回报失败 → 源 issue 评论兜底。
EOF
jq -n --rawfile p wake.txt '{agent:"builder",
  model:{providerID:"<known-good providerID>",modelID:"<known-good 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):

# 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=<provider + reset hint> pr=#N, completion-report.md <report> 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.