Files

548 lines
47 KiB
Markdown
Raw Permalink Normal View History

---
name: headless-session-ops
description: >
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`.
triggers:
# English — the brand phrases for headless launch.
- headless session
- launch session
- prompt_async
- prompt async
- autonomous session
# English — the failure mode (agent sees a stalled session and needs to know why).
- session not generating
- session generation stuck
# English — ticket-driven launch.
- launch session for issue
- launch session for ticket
- spawn session for ticket
# English — patrol mode (delayed self-wake polling loops, [org-internal #3937]).
- patrol loop
- patrol mode
- delayed wake
# Chinese — bare noun phrases (matches() is a contiguous substring).
- 拉起主会话
- 拉起会话
- 自治会话
- 无头会话
# Chinese — patrol mode ([org-internal #3937]).
- 巡检模式
- 延迟唤醒
- 会话不生成
- 会话卡死
- 为工单拉起会话
- 工单拉起会话
# English — lifecycle (hung rescue, stale-worldview correction, [org-internal #2459]).
- abort session
- revive session
- session rescue
- fact baseline
# English — quota-wall recovery ([org-internal #3669]): worker killed by provider usage limit.
- quota wall
- usage limit reached
- quota exhausted
- switch provider
# Chinese — lifecycle ops ([org-internal #2459]).
- 唤醒会话
- 会话挂死
- 事实基线
# Chinese — quota-wall recovery ([org-internal #3669]).
- 配额墙
- 配额耗尽
- 用量上限
- 换供应商
role: 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
> (<<instance-base-url>/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`):
```ts
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`).
## Reuse the current session's model (recommended default)
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`:
```sh
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):
```sh
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:
```sh
BASE=http://127.0.0.1:4096
DIR=/data/octopus
```
### Step 1 — Create the session
```sh
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`)
```sh
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:
```sh
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()`):
```sh
# 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`.
```sh
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_async`s 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`).