Files
octopus-workflow/core/skills/headless-session-ops/reference/ticket-recipe.md
T

183 lines
11 KiB
Markdown
Raw Normal View History

> Extracted from headless-session-ops/SKILL.md (Launch a session for a specific ticket (#N)) — moved verbatim 2026-08-26, ticket [org-internal #3480].
## Launch a session for a specific ticket (#N)
The common case: an agent (or cron/CI) needs to spin up a fresh main session to
work a tracked issue. Compose the seed prompt **from the issue itself** so the
new session starts with real context, and launch it on **the current session's
model**.
0. **Owner check (MANDATORY pre-step, [org-internal #1803]).** Before creating anything,
verify no other live session already owns this ticket or code area —
duplicate ownership is how [org-internal #1744]/[org-internal #1753] collided. The check spans four
data sources; any live claim → **ABORT the launch and report the
conflict**.
**Mechanized ([org-internal #3667])**: `bash script/session-conflicts.sh <N>` runs the
session-title scan (a), the branch scan (d), and the open-PR check (c)
in one read-only pass (exit 4 = conflict, `--json` for a machine
summary); the assignee/claim check (b) is `claim.sh`'s compare-and-swap
at claim time. The manual recipes below remain the fallback and the
normative definition (`core/rules/session-scope-guard.md`).
**a. Session-title scan (live session check).** The listing endpoint
returns at most `limit` (default 100) sessions — on busy repos (300+ live
sessions in one directory) that silently truncates and the scan misses
owners ([org-internal #3190]). Non-range listing responses carry `X-Total-Count` (full
filter population, untruncated) and `X-Has-More: true` (only when the
page is truncated). Scan procedure: fetch page 1, and when
`X-Has-More: true` is present re-fetch once with `limit=$TOTAL` — treat
any failure to obtain the full population as "more owners may exist"
(ABORT or widen the scan — never assume the first page is the whole
population).
```sh
# N must be the digits-only issue number (e.g. N=1803) — never interpolate
# raw issue text here; the regex below assumes digits.
[[ "$N" =~ ^[0-9]+$ ]] || { echo "N must be digits only"; exit 1; }
HDR=$(mktemp)
BATCH=$(curl -sD "$HDR" "$BASE/session?directory=$DIR")
TOTAL=$(awk -F': ' 'tolower($1)=="x-total-count"{print $2}' "$HDR" | tr -d '\r')
if [[ "$TOTAL" =~ ^[0-9]+$ ]] && awk -F': ' 'tolower($1)=="x-has-more"{print $2}' "$HDR" | grep -q true; then
# truncated page + known population — refetch the full set in one request
BATCH=$(curl -s "$BASE/session?limit=$TOTAL&directory=$DIR")
elif awk -F': ' 'tolower($1)=="x-has-more"{print $2}' "$HDR" | grep -q true; then
# truncated page but no usable X-Total-Count (older server) — the
# population is unknown; do NOT re-fetch with the same default limit
# (it would silently re-truncate). Escalate instead.
echo "ERROR: session listing truncated but X-Total-Count unavailable — cannot establish full owner population" >&2
rm -f "$HDR"; exit 1
fi
rm -f "$HDR"
echo "scanned $(echo "$BATCH" | jq 'length') of ${TOTAL:-?} sessions in $DIR" >&2
echo "$BATCH" | jq -r --arg n "$N" '.[] | select(.title | test("#" + $n + "([^0-9]|$)")) | "\(.id)\t\(.title)\t\(.time.updated)"'
```
- Any hit whose `time.updated` is recent (session still active) → **ABORT
the launch and report the conflict** (issue comment naming the owning
session id). Do not launch a second session for the same ticket.
**b. Assignee / claim check ([org-internal #2297]).** Read the issue via
`工单 API(见 TERMINOLOGYget(owner, repo, index: N)` (or `GET /api/v1/repos/$OWNER/$REPO/issues/$N`):
- If `assignee` is set and is **not** the launching agent → **ABORT** and
report (the ticket is already claimed).
- If a claim comment names a **different branch / session id** → **ABORT**
and coordinate on the issue before proceeding.
**c. Open-PR check.** List open PRs referencing `#N` —
`GET <gitea-base-url>/api/v1/repos/issues/search?q="#N"&type=pulls&state=open`
(API source) — and **ABORT** if an open PR already covers the ticket.
**d. Remote branch check ([org-internal #2297]).** A local branch is invisible to other
sessions — check the remote too:
`git ls-remote origin 'workflow/*'` and scan for a branch tail covering `#N`.
Also check the repo side locally: `git worktree list` +
`git branch --list 'workflow/*'` for a branch / worktree already covering #N.
- A stale hit (session idle for hours / clearly abandoned) → do NOT
hand-post a takeover comment (retired admin workaround — unauditable);
dead-claim takeover is mechanized ([org-internal #3668]): `bash script/claim.sh
takeover <ticket> <branch> --session-id <id>`. It enforces the
evidence gate (branch ABSENT on origin AND (session 404 OR comment at
least CLAIM_TAKEOVER_STALE_DAYS old)) and exits 10 when the evidence
does not hold — a valid claim is never superseded; an idle-but-alive
session is NOT provably dead, coordinate on the issue instead (exit 4
= owned/conflict, in claim.sh and session-conflicts.sh alike).
Evidence rules: `core/rules/session-scope-guard.md`
§"Dead-claim takeover" (claim.sh header is normative).
The runtime counterpart of this check is `core/rules/session-scope-guard.md`
("One task, one owner"), injected into every session's prompt.
- **Backend hard guard ([org-internal #1989]):** `POST /session` hard-rejects duplicate-ticket
session creation with HTTP 400. The backend guard is **on by default ([org-internal #2350])**;
set `enabled: false` in config to opt out. It fails open ONLY on defect
paths (DB / config errors), never as a configured disable. The front-end
soft check above is now backed by this backend hard check for defense in
depth.
- **Claim-first ([org-internal #2297]).** Claiming a ticket is one atomic 3-step action:
(1) set the issue assignee to the working account, (2) post a claim comment
naming the workflow branch and session id, (3) push the workflow branch to
remote (`git push -u origin workflow/...`). The claim is valid ONLY when all
three steps complete — an incomplete claim is NOT a claim. Do NOT launch the
session until all three steps are done (see
`core/rules/session-scope-guard.md` §"Claim-first"). If a collision HAS
already happened, follow `rules/ownership-collision-runbook` (wiki, L2 on-demand).
- **Provisioning after claim ([org-internal #3642]) is ONE command** — claim + worktree +
Tier-1 runs scaffold:
`bash script/claim-provision.sh <N> workflow/<branch>` (runs claim.sh with
exit codes passed through, attaches the session worktree to the claimed
branch via `session-worktree.sh create <slug> --branch <branch>`, then
scaffolds `<runs-root>/{slug}/` via `<harness-package>/script/runs-init.ts`
with kind-aware exemptions; prints one JSON summary; idempotent re-runs).
Pitfall #10's pre-built worktree is exactly what it produces — put the
returned worktree path in the claim comment + seed prompt.
1. **Read the issue.** Use the REST API (`gitea-rest` skill; `gitea-mcp` is retired):
```sh
curl -s "$GITEA/api/v1/repos/$OWNER/$REPO/issues/$N" | jq '{title, body}'
```
or `工单 API(见 TERMINOLOGYget(owner, repo, index: N)`.
2. **Compose the seed prompt** from the issue body. The seed MUST, at minimum:
- restate the **goal** in one sentence;
- list the **constraints** and **acceptance criteria**;
- cite every **wiki artifact path** / related issue referenced in the body
(e.g. `{epic-slug}/dag`, `{epic-slug}/dag-nodes/{node-id}`, `[org-internal #1691]`);
- tell the new agent to follow the right pipeline skill for the work type
(implement / bugfix / design / …).
Write it to a file (handles newlines):
```sh
cat > seed.txt <<'EOF'
Work issue #N: <one-line title>.
Goal: <…>.
Constraints: <…>.
Acceptance: <…>.
Artifacts: <wiki paths from the issue body>.
Follow the `implement` skill (Mode: bugfix) for this.
Session scope guard (mandatory): if a test fails and it was NOT caused by
your change — classify (pre-existing/flaky), file a BF/FT issue per
core/rules/session-scope-guard.md, and continue this task. Do NOT fix
unrelated failing tests in place.
## 完成回报协议 (mandatory, [org-internal #2374])
完成、阻塞、或移交时,向编排会话回报:
POST <BASE>/session/<LAUNCHER_SID>/prompt_async?directory=<DIR>
(编排目录未知时可用全局路由 POST <BASE>/prompt_asyncbody 携 sessionID[org-internal #4307]
body: {"agent":"builder","model":{...},"parts":[{"type":"text","text":"<report>"}]}
<report>: [#N] [<node-or-slug>] status=done|blocked|handoff branch=<ref> [pr=#<PR>] verify=<changed+typecheck> risk=<high|low> summary=<one line>
约束:worker 只推分支不开 PR(PR 由编排串行开);交付推送前 test:changed+typecheck 须绿。若再遇配额/用量上限错误,立即回报 status=blocked reason=quota-exhausted summary=<provider 与重置提示>branch=<ref> 必带)后停止,勿自行换模型重试。
回报失败(非 2xx / 连接拒绝)→ 在本 issue(或父 Epic)发同内容评论兜底。
决策边界([org-internal #2378]):遇方向性决策点 → status=blocked 回报,勿调 question。
EOF
```
3. **Determine the known-good model** (reuse the current session's — see the
"Reuse the current session's model" section).
4. **Run the 3-step flow** with `title:"#N — <short>"` and the file-built
payload. Read the model from the launcher session's own record — this also
applies the `model.id → modelID` remap from the "Reuse" section:
```sh
MODEL=$(curl -s "$BASE/session/$LAUNCHER_SID?directory=$DIR" \
| jq -c '.model | {providerID, modelID: .id}')
SID=$(curl -s -X POST "$BASE/session?directory=$DIR" \
-H 'content-type: application/json' \
-d "{\"title\":\"#$N — <short>\",\"agent\":\"builder\"}" | jq -r .id)
PAYLOAD=$(jq -n --argjson m "$MODEL" --rawfile p seed.txt \
'{agent:"builder",model:$m,parts:[{type:"text",text:$p}]}')
curl -s -X POST "$BASE/session/$SID/prompt_async?directory=$DIR" \
-H 'content-type: application/json' -d "$PAYLOAD"
```
5. **Poll** Step 3 until an `assistant` message appears — bounded tail poll
(`&limit=1`), never the no-`limit` full-transcript form. Record `$SID` somewhere
durable (issue comment, CI log) so the session is traceable to the ticket.
6. **Add the completion-report block to the seed prompt** (see next section) so
the worker reports back when it finishes or blocks — without it, this
orchestrator has no push channel and must poll forever ([org-internal #2374]).