Files
octopus-workflow/core/skills/headless-session-ops/SKILL.md
T

47 KiB
Raw Blame History

name, description, triggers, role
name description triggers role
headless-session-ops Use ONLY when an agent must autonomously launch a headless main session — create a session and drive agent generation over the HTTP `prompt_async` endpoint with NO persistent client attached (Web UI / TUI absent) — OR lifecycle-manage such a session afterwards: health-check polling, hung-stream diagnosis (state=generating + frozen message count), abort + re-wake rescue, provider quota-wall recovery (worker killed mid-task by a usage limit — detect, re-wake on a known-good provider, inject facts), and fact-baseline injection to correct a stale worldview in a woken worker. This is the path used by in-session agents spawning sibling sessions, `octopus run --attach` non-interactive mode, and cron / CI drivers. Covers the 3-step flow (create session → POST prompt_async WITH a known-good `model` → poll `/session/:id/message` to verify generation), how to REUSE the current session's model for the new session, the model-field requirement, the directory default (current session's directory unless targeting another repo), and the ticket-driven recipe (launch a session seeded from issue #N). Do NOT use when a persistent client (Web UI / TUI) is driving the session — those use the synchronous `prompt` endpoint and always send `model`.
headless session
launch session
prompt_async
prompt async
autonomous session
session not generating
session generation stuck
launch session for issue
launch session for ticket
spawn session for ticket
patrol loop
patrol mode
delayed wake
拉起主会话
拉起会话
自治会话
无头会话
巡检模式
延迟唤醒
会话不生成
会话卡死
为工单拉起会话
工单拉起会话
abort session
revive session
session rescue
fact baseline
quota wall
usage limit reached
quota exhausted
switch provider
唤醒会话
会话挂死
事实基线
配额墙
配额耗尽
用量上限
换供应商
Producer

Core 中立版(Increment 6a 改写,原 deferHard verbatimDir)。机制、结构与 frontmatter 保持;实例术语(工具名、路径、工单号)按 core/adapters/TERMINOLOGY.md 绑定到具体实例。

Headless Main Session Launch (prompt_async)

Launch a main session for a specific issue / work item from a headless context — no Web UI, no TUI, just HTTP. This is the "fire-and-forget" path: an agent inside another session, a cron job, a CI script, or octopus run --attach non-interactive mode all land here when they need to spin up a session that actually generates.

Directory convention — independent session in the current workspace, no worktree

The default is an independent session sharing the current session's directory — the repo you are already working in. No new worktree, no checkout, no extra bun install or .codegraph/ rebuild. Session creation and worktree discipline are orthogonal: this skill only opens a session record pointed at a directory; whether the launched session later creates its own worktree is a workflow decision (per the worktree-discipline rule) it makes when it starts actual work — not something the launcher does. One exception: the ticket-driven recipe below, where claim-first ([org-internal #2297]) requires the launcher to push the workflow branch before launch, making a pre-built worktree the claim carrier + ready workspace (see Pitfall #10 carve-out). So, unless a different repo / path is specifically targeted, ?directory= defaults to the current session's directory and is NOT mandatory:

  • You are an in-session agent (most common): set DIR to your own session's directory (you already know it from context, or read .directory from your own session record via GET /session/$LAUNCHER_SID). The new session runs independently in that same workspace. Pass an explicit different ?directory= only when the new session must work in another repo — never a worktree of the same repo. The Web UI sidebar groups sessions by directory (sidebar-project.tsx per-workspace workspaceSessions(directory), fetched via session.list({ directory })), so a session pointed at a worktree disappears from the workspace the creator manages. A pre-built ticket worktree is handed to the launched session via the claim comment + seed prompt, NOT via ?directory=.
  • No current session (cron / CI / bare script): the server-side fallback when ?directory= is omitted is the server process's process.cwd() (server/routes/instance/httpapi/middleware/workspace-routing.ts:76), which is wherever the server was started — unreliable. In that context ?directory= remains effectively required.
  • Dispatch convention — directory = code location, not tracker repo. When launching a session for a ticket, set ?directory= to the main workspace checkout that holds the code the ticket edits, which may differ from the repo where the ticket is tracked. Example: <owner>/<backend-repo>#<n> is filed in the ticket-tracker repo but edits the main repo's workflow files → the session's ?directory= is the code checkout root (<workspace-root>/<org>/<repo>), not the tracker repo checkout. Never point ?directory= at a ticket worktree — the worktree goes in the claim comment + seed prompt, not the directory field (sidebar visibility, see bullet 1). The owner-check (GET /session?directory=$DIR) is scoped per directory, so rooting the session at the correct code checkout is also what makes the duplicate-session guard meaningful. Server-side backstop ([org-internal #3190]): creating a session whose directory IS a linked git worktree answers with an X-Session-Directory-Warning response header and a server warn log naming the owning main repo — if you see that header, the session will be invisible to GET /session?directory=<mainRepo> owner-checks; re-check your launch parameters before proceeding.

Authoritative reference: rules/headless-session-ops wiki page (</Octopus/octopus/wiki/rules%2Fheadless-session-ops>). This skill is its runtime carrier. Provenance: issue [org-internal #1695] (which corrected the misdiagnosed [org-internal #1691], see comment 9205). Code citations below were verified against <harness-package> at HEAD.

The model field — always provide a known-good model (read this first)

prompt_async is fire-and-forget: after HTTP returns 204 (or 202 when the message is queued behind a wedge/zombie session — no live runner in that process), the server runs the full agent loop in a separate fiber. Always include an explicit model in the payload, and reuse the current session's model for it (see the next section). Two reasons:

  1. Current code does not hard-require model. The HTTP boundary marks model as optional (<harness-package>/src/session/prompt.ts:1860), and createUserMessage fills a missing model via a fallback chain (prompt.ts:858):

    const model = input.model ?? ag.model ?? yield * currentModel(input.sessionID)
    //                         ^^^^^^^^^ agent  ^^^^^^^^^^^^^^^^^ session/default
    

    So omission does not produce a clean validation error — it silently resolves to the agent's model, the session's last-used model, or the provider default (currentModel, prompt.ts:824-838).

  2. But the fallback is not safe in the autonomous context. If it resolves to a model that is unusable here (region-blocked, wrong subscription tier, not configured), the runLoop's getModel raises ModelNotFoundError (prompt.ts:1399, :804-822). In the async handler that error is caught by the delivery fork's failure handling (forkPromptDelivery in handlers/session.ts) and never reaches the HTTP caller — so the session looks healthy (HTTP 204, user message persisted) yet never generates. This is exactly the "silent stall" observed empirically in [org-internal #1695]: a session with a 1-message transcript (user only) and frozen time.updated.

payload result
{agent, parts} — no model 204/202; model filled by fallback; if the resolved model is unusable → apparent silent stall (no error surfaced, no generation).
{agent, model:{providerID,modelID}, parts} 204/202; deterministic generation within seconds (verified: "1+1 等于 2。 in ~6s).

Iron Law: every prompt_async payload MUST carry a known-good model, and the safest known-good model is the one driving this session right now (it is, by definition, generating). Never rely on the fallback chain in a headless/autonomous launch.

Field-name gotcha when copying a model: the prompt payload's ModelRef uses { providerID, modelID } (prompt.ts:1852-1855), but the session record's model uses { id, providerID, variant } (session.ts:256-260) — i.e. modelID is called id. When you reuse a model read from a session record, remap model.id → modelID (see recipe below). User-message info.model already uses modelID (message.ts:403-407).

The model powering the launching session is proven to work — reuse it. Discover it by whichever path fits your context:

  • You are an in-session agent (most common): read it straight from your own system prompt, which states verbatim:

    "You are powered by the model named {modelID} (provider: {providerID})."

    e.g. modelID = "glm-5.2", providerID = "zai-coding-plan"(曾名 zhipuai-coding-plan2026-08 更名)

  • Programmatic / no system prompt (cron, CI, sibling caller): GET the launching session's record and read .model:

    curl -s "$BASE/session/$LAUNCHER_SID?directory=$DIR" \
      | jq '.model'   # → { "id": "glm-5.2", "providerID": "zai-coding-plan", "variant": ... }
    

    Then remap model.id → modelID when building the payload.

  • Verify the provider is actually connected before launching (cheap insurance — the model only generates if its provider is live):

    curl -s "$BASE/provider?directory=$DIR" | jq '.connected | index("<current providerID>")'
    # → null = provider NOT connected (do NOT launch); 0..n = connected
    

    or call the list-models tool. Note: model-level availability is only knowable at runtime — a configured model can still fail (subscription-tier or region errors); GET /config/providers lists configured models, not usable ones. The safest pre-flight is "provider connected" + reusing the current session's model, which is generating by definition. There is no GET /model HTTP endpoint (it 404s); do not use it.

Prerequisites

  • A reachable octopus HTTP instance (e.g. http://127.0.0.1:4096).
  • The target directory (passed as ?directory=). Defaults to the current session's directory for in-session launches — see the Directory convention above; only specify a different one explicitly when targeting another repo.
  • A known-good model (reuse the current session's — see above). Model IDs must match the provider config in <config-home>/octopus/octopus.jsonc; credentials live in the octopus account keyring (not env), so an isolated HOME cannot reuse them.

The 3-step flow

Set the base URL and directory once:

BASE=http://127.0.0.1:4096
DIR=/data/octopus

Step 1 — Create the session

SID=$(curl -s -X POST "$BASE/session?directory=$DIR" \
  -H 'content-type: application/json' \
  -d '{"title":"#N — <short description>","agent":"builder"}' | jq -r .id)
echo "session=$SID"
  • POST /session returns Session.Info with .id (groups/session.ts:207-211).
  • ?directory= points at the repo root. For in-session launches it defaults to the current session's directory (Directory convention above) — keep it that way so the new session stays visible in the creator's sidebar; pass an explicit path only when targeting a different repo (a worktree of the same repo is NOT a directory target — hand it over via the seed prompt), and always pass it from cron / CI where no current session exists (server falls back to its own process.cwd() otherwise).
  • agent is builder (the main agent). Sub-agents (explorer/worker) are spawned within a session — never named here.

Step 2 — Deliver the seed prompt (CRITICAL: include a known-good model)

curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \
  -H 'content-type: application/json' \
  -d '{
    "agent": "builder",
    "model": { "providerID": "<current providerID>", "modelID": "<current modelID>" },
    "parts": [ { "type": "text", "text": "<seed prompt: restate goal, constraints, acceptance criteria, cite wiki artifact paths>" } ]
  }'
  • Endpoint is POST /session/:sessionID/prompt_async; it answers 204, no body when processing starts now (idle session, live runner, noReply injection, or waiting-question preemption), 202 when the message is queued behind a session that is generating with no live runner in this process (wedge/zombie shape — stored and re-driven on restart drain or the next runner), 202 when delay_sec defers delivery to an in-memory delayed wake ([org-internal #3937] A — see "巡检模式 / Patrol mode" below), and 409 when the session is paused (message NOT stored) or the maintenance-mode hold queue is full.
  • Global route alternative ([org-internal #4307]): when the client only knows the sessionID (no ?directory= routing), POST /prompt_async (no path prefix) resolves the instance from the app database by the body's sessionID and delegates to the same delivery core — same 204/202/409 semantics, same delay_sec contract, body is the session-scoped payload plus a required sessionID field. Unknown sessionID → 404 naming the session-scoped route. This is the natural fit for the completion-report POST (fixed port, no directory context).
  • delay_sec (optional, integer 0..86400, else 400) schedules the prompt for a one-shot delayed self-wake: the server holds the prompt in memory and fires it exactly once after delay_sec seconds, only for an idle session at intake. Precedence ([org-internal #3937] review): delay_sec applies only when the intake would otherwise start processing now (immediate class). For the queued-behind-wedge 202 member and under maintenance queue mode it is stripped — retention semantics win (the server logs the drop; the delay is NOT honored). Pending wakes are in-memory only — a server restart abandons them; a patrol loop must therefore tolerate a missed wake and re-arm. Never use sleep <big>; curl ... in a bash tool call to wait — that blocks the turn with zero output (the [org-internal #3937] incident shape); the bash tool now warns on bare sleeps > 120s (long-sleep guard, [org-internal #3937] C).
  • model is { providerID, modelID } or the shorthand string "providerID/modelID" (split on the FIRST /; accepted since [org-internal #4307] — e.g. "zai-coding-plan/glm-5.2"). Do not omit it. Fill the values from your own session's model (see "Reuse the current session's model" above) — never hardcode a specific model. A string without a / is rejected with 400 naming both accepted forms.
  • When the seed prompt contains newlines/quotes, build the payload from a file to avoid shell-escaping errors:
    jq -n --rawfile p seed.txt '{agent:"builder",model:{providerID:"<current providerID>",modelID:"<current modelID>"},parts:[{type:"text",text:$p}]}'
    

Step 3 — Verify the session actually generated (do NOT assume success)

HTTP 204/202 ≠generation success. Poll until an assistant message appears. The endpoint is /session/:id/message — SINGULAR, and it returns a bare array whose elements are { info, parts } with role at .info.role (groups/session.ts:88,183-194). Poll the tail with limit=1 — the no-limit form loads the ENTIRE transcript server-side (O(messages×parts) per poll; pitfall #14's 478-message session paid it on every health check), while limit=1 returns just the newest message (REQ-F-010 windowing, message.ts page()):

# wait 510s, then poll the TAIL — never the full transcript
curl -s "$BASE/session/$SID/message?directory=$DIR&limit=1" \
  | jq '{latest_role: .[0].info.role, latest_id: .[0].info.id}'

Verdict: latest_role == "assistant" → the first generation completed (the seed is user; the newest message flips to assistant only once the model answered — an errored turn also persists an assistant message carrying the ⚠️ usage-limit part, [org-internal #2912], so check the tail text before declaring success). Stays user across 23 polls spaced 1030 s → apparent silent stall; almost certainly the model did not resolve to a usable one (fallback landed badly, or you omitted model). Redeliver Step 2 with an explicit known-good model. Need everything newer than a known point? Page forward with after=<cursor>&limit=N and follow the X-Next-Cursor response header — just never omit limit.

⚠️ Do NOT write /messages (plural) — that path does not exist for listing. (POST /session/:id/message, same singular path but POST, is the synchronous prompt endpoint; don't confuse the two.)

Launch a session for a specific ticket (#N)

The common case: an agent (or cron/CI) spins up a fresh main session to work a tracked issue — seed prompt composed from the issue body, launched on the current session's model. Step-0 owner check is MANDATORY ([org-internal #1803]): four data sources (session-title scan, assignee/claim, open PRs, remote branches); any live claim → ABORT the launch (session-scope-guard.md points here for that pre-step). Full recipe — owner-check scan script with X-Total-Count / X-Has-More pagination handling ([org-internal #3190]), claim-first atomic 3-step ([org-internal #2297],

  • one-command provisioning via script/claim-provision.sh, [org-internal #3642]), seed-prompt MUSTs, backend duplicate-ticket hard guard ([org-internal #1989], on by default [org-internal #2350]), launch + poll commands: reference/ticket-recipe.md (read BEFORE creating any ticket-driven session).

Completion-report protocol (完成回报协议, [org-internal #2374])

prompt_async is fire-and-forget for the launcher too: without this protocol the orchestrator has NO push channel and must poll forever ([org-internal #2366]). The worker actively reports status=done|blocked|handoff back into the orchestrator's session via prompt_async, with an issue-comment fallback. Default report format is branch=<ref>-based — workers never open PRs (TD-678/[org-internal #4425]); pr=#N appears only on uncoordinated self-opens. Hard constraints: a headless worker MUST NOT call the question tool ([org-internal #2378] — directional/irreversible decisions are status=blocked reports, then STOP), and MUST NOT self-merge its PR / self-close the issue / touch main ([org-internal #2386] — keep the PR open; the orchestrator merges and closes). Full protocol — orchestrator-side seed block (verbatim template), worker-side steps, decision-authority layering: reference/completion-report.md (read BEFORE composing a seed prompt for any worker you need to hear back from).

Post-launch lifecycle: health check, hung rescue, stale-worldview correction ([org-internal #2459])

Launching is half the job — a headless worker can hang silently or wake with a stale worldview. Health check = two polls 3060 s apart (.state + progress

  • message count); state=generating with ALL counters frozen across BOTH samples = hung → abort FIRST (POST /session/:id/abort), then re-wake with a known-good model + the anti-re-hang clause. Stale worldview → fact-baseline injection (only the CURRENT authority injects; if YOU might be the stale one, verify identity against the durable record first). Full runbooks — verdict table + [org-internal #3215] dual-sample rule, abort + re-wake sequence, fact-baseline structure, authorization asymmetry: reference/lifecycle-ops.md (read when managing a launched session). Quota wall — the worker's turn killed by a terminal usage-limit error (looks like silent completion: no report, ticket stalled) — has its own runbook: detection signals (finish=error ⚠️ tail, provider quota markers), provider-switch re-wake, fact-baseline template, pre-dispatch provider check: reference/quota-wall-recovery.md ([org-internal #3669], evidence [org-internal #3627]; read when a worker goes silent mid-task).

巡检模式 / Patrol mode (delayed self-wake, [org-internal #3937])

A patrol loop periodically nudges a headless session without holding a connection or burning a turn on a blocking sleep. Since [org-internal #3937] A the server supports this natively via delay_sec on prompt_async.

curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \
  -H 'content-type: application/json' \
  -d '{
    "agent": "builder",
    "model": { "providerID": "<current providerID>", "modelID": "<current modelID>" },
    "parts": [ { "type": "text", "text": "<patrol instruction>" } ],
    "delay_sec": 3600
  }'

Rules of the road:

  • 202 + delay_sec ≠queued-behind-wedge 202. Both are 202, but the delayed wake holds the prompt in memory and delivers it exactly once when the delay elapses (session must have been idle at intake; 0 equals omission → immediate 204 path). If the session was NOT idle at intake, the intake keeps the queued semantics and delay_sec is ignored (logged server-side) — the same precedence as maintenance queue mode, where the intake is retained and delay_sec never delays the flush.
  • Paused at wake → dropped, not stored. The wake's prompt() rejects and the server publishes an error event; the message is never stored. A patrol driver should check session state before assuming delivery.
  • Restart abandons pending wakes. They are in-memory server fibers — no persistence, no drain. After any server restart the patrol driver owns re-arming the next wake; treat a missed wake as expected, not as an error.
  • Latest wake wins, per session ([org-internal #4069]). Arming delay_sec on a session that already has a pending wake SUPERSEDES it: the previous countdown is cancelled, only the latest wake fires. Re-arming each turn is the intended patrol pattern — a superseded wake never delivers its prompt and publishes no error. (Pre-[org-internal #4069] stacks both FIFO — on old binaries, re-arm only after the previous wake fired.)
  • Never emulate a delay with the bash tool. sleep 900; curl ... blocks the turn with zero output and gets manually aborted as a hang (the [org-internal #3937] incident). The bash tool now prepends a <shell_warning> on bare sleeps beyond 120s ([org-internal #3937] C) — treat that warning as a redirect to this section.

Do NOT use this skill when

  • A persistent client (Web UI / interactive TUI) is attached — those use the synchronous prompt endpoint (it blocks until generation completes) and send model automatically. Using prompt_async there gains nothing and loses the in-line response. This includes the rescue runbook: a hung session driven by a persistent client is the client user's to handle (refresh / re-prompt from the UI) — abort + re-wake targets headless / managed sessions only.
  • You want the response inline in the same HTTP call — prompt_async is fire-and-forget; use the synchronous prompt endpoint instead.

Common pitfalls

# Pitfall Fix
1 Omitting model / relying on the fallback → apparent silent stall (most common) Always send a known-good model:{providerID,modelID} — reuse the current session's
2 Listing endpoint written /messages (plural) → 404 Use /session/:id/message (singular); returns a bare array, role at .info.role
3 Copying the session record's model verbatim into the payload Remap field names: session .model.id → payload modelID (session uses id, payload uses modelID)
4 Stale octopus binary rejects newer config schema (Unrecognized key) Use the source CLI bun run ./src/index.ts ...; the config itself is valid
5 Missing ?directory= query param In-session launch: default to the current session's directory. Cron/CI (no current session): pass the repo root explicitly — server falls back to its own process.cwd()
6 Using synchronous prompt for headless launch prompt blocks until done — wrong for fire-and-forget; use prompt_async
7 Shell-escaping errors in a multi-line seed prompt Build the payload with jq -n --rawfile from a file
8 Verifying models via GET /model — no such endpoint (404) Use GET /provider.connected (provider live check); list-models tool also works. GET /config/providers lists configured models only
9 Launching a session for a ticket another live session already owns → conflicting diffs, duplicate work ([org-internal #1744]/[org-internal #1753]) Run the Step-0 owner check (GET /session title scan + worktree/branch/PR scan); ABORT and report on conflict
10 Creating a worktree / fresh checkout for the new session (unnecessary bun install + .codegraph/ rebuild cost) Don't — for general launches: a session is a conversation pointed at a directory, so reuse the current workspace; worktree creation is a separate workflow decision the launched session makes if/when it starts a workflow. Ticket-recipe carve-out ([org-internal #2297]): the ticket's work happens in a dedicated worktree anyway (worktree discipline), and claim-first pushes the workflow branch before launch — so the launcher pre-builds the worktree as the claim carrier + ready workspace, and the session reuses it (cost is front-loaded, not wasted). The worktree path goes in the claim comment + seed prompt; ?directory= stays on the creator's workspace
11 Launching a worker and never hearing back — orchestrator polls a few times, stops, loses visibility ([org-internal #2374]) Append the completion-report block to the seed prompt (Completion-report protocol section): worker prompt_asyncs a one-line status back into the orchestrator session on done/blocked/handoff, with issue-comment fallback
12 Headless worker calls the question tool → no attached client: the question deadlocks in a queue, or auto_approve silently auto-approves a direction-setting decision ([org-internal #2378]) NEVER call question from a headless worker. Decision boundary (Completion-report protocol section): directional/irreversible decision → status=blocked report + one-line decision point, then STOP; the orchestrator is the sole human-decision entry point
13 Headless worker self-merges its PR / self-closes the issue once the PR looks ready, despite a seed instruction to keep it open ([org-internal #2386]; N-02 [org-internal #2367] / N-03 [org-internal #2368], commits d1565c99 / 040f21b7) Self-merge / self-close / touching main are irreversible directional actions → blocked by the Decision boundary. PR ready → keep it OPEN, report status=done branch=<ref>; the orchestrator opens/admits the PR, merges and closes uniformly
14 Trusting state=generating as "alive" — a hung stream goes unrescued (session A, 2026-08-16: generating 6+ min, count frozen at 478) Poll state AND message count twice 3060 s apart; frozen count = hung → abort + re-wake (Post-launch lifecycle section)
15 Re-sending a wake prompt to a hung session and waiting — queued prompts are never consumed while the stream is dead Abort FIRST (POST /session/:id/abort → 200, verify idle), THEN re-send; a prompt queued before the abort may still never fire — always re-deliver
16 A worker woken from restart / long idle acts on its stale worldview — re-dispatches superseded work or claims authority it no longer holds (2026-08-16: revived worker re-ran an already-published FAIL verify; ops session announced an inherited "orchestrator" takeover) Fact-baseline injection (Post-launch lifecycle section): authoritative facts + invalidated assumptions + one concrete re-assignment. Only the CURRENT authority injects; a session that suspects IT is stale verifies identity against the durable record first (rules/compact.md identity clause)
17 Worker goes silent mid-task: provider quota wall killed the turn (usage limit / 429) — no completion-report, no blocked report; from the issue side indistinguishable from silent completion (2026-08-29 [org-internal #3627]: opencode-go weekly usage limit reached after the 13:18 Round-1 FAIL; ticket stalled until manually re-woken) Detect via tail probe (finish=error + ⚠️ usage-limit part, metadata.reason in the quota family) + GET /provider quota markers; re-wake on a known-good provider with the fact-baseline template — reference/quota-wall-recovery.md ([org-internal #3669]). Prevent: pre-dispatch provider check + the seed's 配额自报 clause (reference/completion-report.md, [org-internal #3669]) — quota exhaustion is a status=blocked reason=quota-exhausted report, not a silent death

Known limitation (separate from this workaround)

A missing model producing an apparent silent stall (no surfaced error, no generation) is an observability / robustness gap. Current code does NOT hard-require model — it fills it via a fallback chain (prompt.ts:858) and any resolution failure surfaces as a published Session.Event.Error on the synchronous path (prompt.ts:804-822), but the async prompt_async handler catches that cause (forkPromptDelivery in handlers/session.ts) so the HTTP caller never sees it. The proper fix is to surface the error (or reject unusable models at the boundary) so a stalled session is diagnosable. Until then, always provide an explicit known-good model — this skill is the documented workaround (see [org-internal #1695] "遗留 minor").

Update ([org-internal #2912], 2026-08-20): usage-limit turn failures (HTTP 429 / FreeUsageLimitError / GoUsageLimitError) are no longer silent shells. When such a turn terminates (retry schedule ends or the spin is aborted), the errored assistant message persists finish=error plus a human-readable ⚠️ text part carrying provider, limit reason, and reset hint (metadata: { error: true, reason, provider }) — visible via GET /session/:id/message and rendered in the web UI like any assistant text. Pitfall #14's double-poll is still the liveness check while a 429 retry spin is IN progress (the spin itself retries with backoff until interrupted); the explicit error part only appears once the turn ends. The general async-path observability gap above remains tracked by [org-internal #1695].

See also

  • Source issue / full manual: [org-internal #1695] (this skill adds the ticket-driven recipe
    • current-model reuse, and corrects the endpoint/claim against current code).
  • Completion-report protocol: [org-internal #2374] (worker-side active report back to the launching orchestrator session; fallback to issue comment).
  • Quota-wall recovery recipe: [org-internal #3669] (evidence instance [org-internal #3627]) — detect a worker killed by a provider usage limit, re-wake on a known-good provider, pre-dispatch prevention.
  • Decision boundary: [org-internal #2378] (headless worker MUST NOT call question; directional decisions go through status=blocked reports to the orchestrator) and [org-internal #2386] (the worker MUST NOT self-merge its PR, self-close the issue, or touch main — the orchestrator merges and closes uniformly).
  • Correction of the prior misdiagnosis: [org-internal #1691] (closed Invalid), comment 9205.
  • Synchronous client always sends model: packages/app/src/components/prompt-input/submit.ts:158-165.
  • Code citations: <harness-package>/src/session/prompt.ts:858,804-822,1399, handlers/session.ts forkPromptDelivery (async failure handling), server/routes/instance/httpapi/groups/session.ts.

References

On-demand references (NOT injected — read at the declared timing):

  • reference/ticket-recipe.md — ticket-driven launch (Launch a session for a specific ticket (#N)): read BEFORE creating any session for issue #N — Step-0 owner check, seed-prompt composition, launch + poll commands.

  • reference/completion-report.md — completion-report protocol (完成回报协议, [org-internal #2374]): read BEFORE composing a seed prompt for a worker you need to hear back from — orchestrator block template, worker-side report steps, decision boundary ([org-internal #2378] / [org-internal #2386]), quota self-report clause ([org-internal #3669]).

  • reference/lifecycle-ops.md — post-launch lifecycle ([org-internal #2459]): read when managing a launched session — health-check polling, hung-stream diagnosis (state=generating + frozen counters), abort + re-wake rescue, fact-baseline injection, authorization asymmetry.

  • reference/quota-wall-recovery.md — quota-wall recovery ([org-internal #3669], evidence [org-internal #3627]): read when a worker goes silent mid-task (suspected provider usage limit) or BEFORE dispatching a long-running worker — detection signals (finish=error ⚠️ tail, provider quota markers, log grep patterns), provider-switch re-wake + fact-baseline template, pre-dispatch provider check, quota self-report clause.

  • rules/headless-session-ops wiki page — authoritative reference (this skill is its runtime carrier; link in the Directory-convention note above).

  • core/rules/session-scope-guard.md — owner-check + claim-first rule (the ticket recipe's Step 0 is its launch-time application).

  • core/rules/compact.md — identity-verification clause backing the authorization-asymmetry rule (reference/lifecycle-ops.md).