v0.1.3: encoding remediation - restore 11 core files to clean UTF-8 + add check:encoding-health guard

This commit is contained in:
octopus
2026-09-16 13:13:18 +08:00
parent 445940ea63
commit 4518f41fd4
14 changed files with 437 additions and 341 deletions
+150 -150
View File
@@ -1,94 +1,94 @@
---
name: headless-session-ops
description: >
Use ONLY when an agent must autonomously launch a headless main session 鈥?
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
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 鈥?
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 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
is driving the session those use the synchronous `prompt` endpoint and
always send `model`.
triggers:
# English 鈥?the brand phrases for headless launch.
# 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).
# English the failure mode (agent sees a stalled session and needs to know why).
- session not generating
- session generation stuck
# English 鈥?ticket-driven launch.
# 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]).
# 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]).
# 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.
# 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]).
- 閰嶉澧?
- 閰嶉鑰楀敖
- 鐢ㄩ噺涓婇檺
- 鎹緵搴斿晢
# 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` 缁戝畾鍒板叿浣撳疄渚嬨€?
> 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
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
## 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
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
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
@@ -100,7 +100,7 @@ carve-out). So, unless a different repo / path is specifically targeted,
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
**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
@@ -109,23 +109,23 @@ carve-out). So, unless a different repo / path is specifically targeted,
- **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=`
is wherever the server was started unreliable. In that context `?directory=`
remains effectively required.
- **Dispatch convention 鈥?directory = code location, not tracker repo.** When
- **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
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 +
`?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
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.
@@ -135,10 +135,10 @@ carve-out). So, unless a different repo / path is specifically targeted,
> 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)
## 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
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
@@ -154,7 +154,7 @@ section). Two reasons:
// ^^^^^^^^^ agent ^^^^^^^^^^^^^^^^^ session/default
```
So omission does **not** produce a clean validation error 鈥?it silently
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`).
@@ -164,15 +164,15 @@ section). Two reasons:
(`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
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). |
| `{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**
@@ -181,14 +181,14 @@ section). Two reasons:
**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`
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
`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
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
@@ -196,27 +196,27 @@ it by whichever path fits your context:
> "You are powered by the model named **{modelID}** (provider: **{providerID}**)."
e.g. `modelID = "glm-5.2"`, `providerID = "zai-coding-plan"`锛堟浘鍚?
zhipuai-coding-plan锛?026-08 鏇村悕锛?
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": ... }
| jq '.model' # { "id": "glm-5.2", "providerID": "zai-coding-plan", "variant": ... }
```
Then **remap** `model.id 鈫?modelID` when building the payload.
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):
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
# 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
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
@@ -226,9 +226,9 @@ it by whichever path fits your context:
- 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
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
- 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.
@@ -242,27 +242,27 @@ BASE=http://127.0.0.1:4096
DIR=/data/octopus
```
### Step 1 鈥?Create the session
### 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)
-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
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
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.
spawned within a session never named here.
### Step 2 鈥?Deliver the seed prompt (CRITICAL: include a known-good `model`)
### Step 2 Deliver the seed prompt (CRITICAL: include a known-good `model`)
```sh
curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \
@@ -278,17 +278,17 @@ curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \
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
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**
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
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
`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
@@ -297,16 +297,16 @@ curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \
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
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
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] 鈥?
`"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) 鈥?
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
@@ -315,87 +315,87 @@ curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \
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)
### 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
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(messagesparts) per
(`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 5鈥?0s, then poll the TAIL 鈥?never the full transcript
# 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
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 鈿狅笍
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 2鈥? polls spaced 10鈥?0 s 鈫?**apparent silent stall**;
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 鈥?
`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.
> ⚠️ 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
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` /
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])
## 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
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,
([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
`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 30鈥?0 s apart (`.state` + `progress`
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
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
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
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,
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])
## 巡检模式 / 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
@@ -414,82 +414,82 @@ curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \
Rules of the road:
- **202 + `delay_sec` 鈮?queued-behind-wedge 202.** Both are 202, but the
- **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
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
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
- **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
- **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
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.
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
- 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
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`) |
| 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` |
| 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 30鈥?0 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 |
| 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
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").
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
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
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
@@ -502,13 +502,13 @@ turn ends. The general async-path observability gap above remains tracked by
- 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
- 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
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`:
@@ -519,29 +519,29 @@ turn ends. The general async-path observability gap above remains tracked by
## References
**On-demand references** (NOT injected 鈥?read at the declared timing):
**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 鈥?
- `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 (瀹屾垚鍥炴姤鍗忚,
- `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
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
- `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
- `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),
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
- `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
- `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
- `core/rules/compact.md` identity-verification clause backing the
authorization-asymmetry rule (`reference/lifecycle-ops.md`).