From 747ed78cde59c14671bdc509bab0f1d463696691 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 18:47:09 +1000 Subject: [PATCH 01/24] chore(porch): 1233 init pir --- .../status.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 codev/projects/1233-builder-crash-restart-loses-al/status.yaml diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml new file mode 100644 index 000000000..cab68cd70 --- /dev/null +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -0,0 +1,18 @@ +id: '1233' +title: builder-crash-restart-loses-al +protocol: pir +phase: plan +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: pending + dev-approval: + status: pending + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-08-05T08:47:09.607Z' +updated_at: '2026-08-05T08:47:09.608Z' From 68e5a58129d172c121c9349a36cf4ee4a4816d9d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 18:53:08 +1000 Subject: [PATCH 02/24] [PIR #1233] Plan draft --- .../1233-builder-crash-restart-loses-al.md | 152 ++++++++++++++++++ codev/state/pir-1233_thread.md | 13 ++ 2 files changed, 165 insertions(+) create mode 100644 codev/plans/1233-builder-crash-restart-loses-al.md create mode 100644 codev/state/pir-1233_thread.md diff --git a/codev/plans/1233-builder-crash-restart-loses-al.md b/codev/plans/1233-builder-crash-restart-loses-al.md new file mode 100644 index 000000000..7885edab3 --- /dev/null +++ b/codev/plans/1233-builder-crash-restart-loses-al.md @@ -0,0 +1,152 @@ +# PIR Plan: Builder crash-restart resumes the session instead of respawning fresh + +## Understanding + +Every builder runs inside a generated `.builder-start.sh` wrapper. Since #1244/#1267 the wrapper's loop is produced by `buildLaunchLoop(initial, fresh)` (`packages/codev/src/agent-farm/commands/spawn-worktree.ts:839`) with a shared tail (`launchLoopTail`, `spawn-worktree.ts:803`) that branches on exit code: + +- **Clean exit (0)** — deliberate quit: clear screen, wait for Enter, relaunch the *fresh* invocation (#1267). +- **Unnatural exit (nonzero / signal, bash reports 128+N)** — sleep 2, rerun the loop's current command. + +The bug is in the unnatural branch of a **fresh spawn**: the loop's only command is the fresh, prompt-carrying invocation (`claude "$(cat .builder-prompt.txt)"`), so a jetsam SIGKILL (137) respawns a brand-new session that re-reads the spawn prompt with total amnesia. My own worktree's `.builder-start.sh` is a live specimen of exactly this. Only the *recovery* variant (`startBuilderSession` with a `resume` object, `spawn-worktree.ts:944-949`) enters on `--resume ` and therefore survives crashes. + +The architect side already solved this end-to-end (#832/#1145/#1149/#1224/#1264): mint a UUID at spawn, pin via `harness.session.newSessionArgs`, resume the pinned id on crash restarts, degrade to fresh when the session is unresumable, and mint a **new** id for the fresh rerun after a clean exit (`tower-utils.ts:397-401, 458-529`). Builders can't reuse that machinery directly — architects are Node-spawned by the shellper, builders are a generated bash script — so the same state machine must be expressed in the generated script. + +## Proposed Change + +Teach the generated launch loop a per-lifetime session state machine, gated on harness support. For the Claude harness (the only one with `HarnessProvider.session`): + +### 1. Mint and pin at spawn + +`startBuilderSession` mints `crypto.randomUUID()` and the initial invocation becomes: + +``` +claude --session-id "$codev_session_id" "$(cat .builder-prompt.txt)" +``` + +Fresh mint per spawn only — never a persisted id reused across spawns (#1224 lesson). Only the crash loop *within one wrapper lifetime* resumes it. + +### 2. Crash restart resumes instead of replaying the prompt + +The nonzero branch switches the loop to a resume invocation: + +``` +claude --resume "$codev_session_id" '' +``` + +No role fragment (the transcript already contains it — same rule as the existing resume path). The **nudge prompt** is essential for autonomy: `--resume` without a prompt restores the conversation but leaves the agent idle waiting for input, which for an unattended builder converts amnesia into a stall. The nudge is a fixed short message ("You were automatically restarted after a crash; your conversation is restored. Re-check state — `porch next ` in strict mode — and continue."). Implementation will verify empirically that `claude --resume ""` accepts a positional prompt before relying on it (fallback if not: resume without prompt and document the stall risk as strictly better than amnesia). + +### 3. Unresumable-session degrade (bounded fast-fail fallback) + +A `--resume` against a gone/corrupt jsonl or a held id (#1145/#1149/#1224 lessons) dies fast and would otherwise crash-loop every 2s forever. The wrapper counts **consecutive fast failures** (nonzero exit with elapsed runtime `< $codev_fast_fail_secs`, default 15, overridable via `CODEV_LAUNCH_FAST_FAIL_SECS` for tests, measured with bash `$SECONDS`). Three in a row → mint a **new** id, fall back to the pinned fresh (prompt-replay) invocation — i.e. today's behavior, but crash-protected going forward. A slow failure resets the counter. + +### 4. Clean exit: stay fresh (per #1267), but pin a new id + +**Design question the architect flagged: should the Enter-gated relaunch after a clean exit resume instead?** My answer: **no — keep fresh, with a newly minted id.** + +Argument: #1267 (builders) and #1264 (architects) both shipped, deliberately and recently, the rule that a clean exit means the user ended *that conversation*, and the relaunch "must not" revive it — #1267 exists precisely because the resume variant's relaunch resumed the conversation the user had just quit. Reversing that here would flip shipped semantics twice in two releases and resurrect the original complaint. The continuity the architect wants is real but already served: a user who wants their context back has `afx spawn --resume` / recover; a user who double-Ctrl+C'd and pressed Enter chose a fresh start. What the relaunch *was* missing is crash protection — so the relaunch mints a **new** UUID and runs the pinned fresh invocation, mirroring `buildArchitectFreshLaunch` (#1264: "each rerun is a genuinely new conversation and needs its own id"). Subsequent crashes then resume the *new* conversation, never the superseded one (sticky, one-way — preserving #1267's invariant). + +Because the new id is minted at runtime in bash, the script needs a mint helper: `uuidgen | tr '[:upper:]' '[:lower:]'` (macOS/util-linux), falling back to `/proc/sys/kernel/random/uuid` (Linux), falling back to an **unpinned** fresh invocation — today's exact behavior, graceful degradation. While the loop is on the unpinned command, a crash reruns it unpinned (never `--resume` of a stale id). + +### 5. Harness seam (no Claude flags outside the harness) + +`HarnessProvider.session` grows optional script-fragment forms, mirroring the existing dual-form convention (`buildRoleInjection`/`buildScriptRoleInjection`, `buildResume` returning both `args` and `scriptFragment`): + +```ts +session?: { + newSessionArgs(sessionId: string): string[]; + resumeArgs(sessionId: string): string[]; + /** Script-fragment forms; idExpr is a pre-quoted shell expression, e.g. `"$codev_session_id"`. */ + newSessionScriptFragment?(idExpr: string): string; // claude: `--session-id ${idExpr}` + resumeScriptFragment?(idExpr: string): string; // claude: `--resume ${idExpr}` + verifyOwnership?(...): boolean; +} +``` + +The session-aware loop is generated only when both fragment forms exist. Codex / Gemini / OpenCode / custom harnesses (no `session`) get the current loop **byte-for-byte** — zero behavior change, verified by test. + +### 6. Persist the current id for later unification (#1112 — coordinate, don't absorb) + +The wrapper maintains `.builder-session-id` in the worktree: written at spawn by Node, rewritten by bash on every re-mint. Key input for #1112: the *bash script* is the only party that knows the current id after a clean-exit or degrade re-mint, so a spawn-time-only DB write would go stale — the worktree file (or a bash-side update hook) is the accurate source. This PR only *writes* the file; consuming it in `afx spawn --resume` / `workspace recover` (replacing mtime discovery) stays in #1112. `buildResume` mtime discovery is untouched here. + +### 7. Entry-on-resume (recover) path + +Unchanged entry semantics: `initial` remains the harness's discovered-id resume fragment (no nudge — recover flows have a human in the loop). But the loop around it becomes session-aware with `codev_session_id` preset to the discovered id: a crash after recovery resumes the same conversation *with* the nudge, a clean exit re-mints and goes pinned-fresh (today it switches to *unpinned* fresh — strict improvement, same #1267 semantics). + +### Generated script shape (Claude harness, fresh spawn) + +```bash +codev_session_id='' +codev_fast_fail_secs="${CODEV_LAUNCH_FAST_FAIL_SECS:-15}" +codev_mint_session_id() { ... uuidgen → /proc fallback → empty ... } +codev_persist_session_id() { printf '%s\n' "$codev_session_id" > '.builder-session-id'; } +codev_launch_pinned() { claude --session-id "$codev_session_id" "$(cat '')"; } +codev_launch_unpinned() { claude "$(cat '')"; } # degraded: no mint available +codev_launch_resume() { claude --resume "$codev_session_id" ''; } +codev_relaunch_fresh() { new id → pinned, else unpinned; persist; } +codev_launch=codev_launch_pinned # (= a plain initial fn on the recover path) +codev_fast_fails=0 +codev_persist_session_id +while true; do + codev_started=$SECONDS + "$codev_launch" + status=$? + codev_elapsed=$(( SECONDS - codev_started )) + if [ "$status" -eq 0 ]; then + clear; echo "...Press Enter to relaunch fresh..."; read -r || exit 0 + codev_relaunch_fresh; codev_fast_fails=0; continue + fi + if [ "$codev_elapsed" -lt "$codev_fast_fail_secs" ]; then codev_fast_fails=$((codev_fast_fails+1)); else codev_fast_fails=0; fi + if [ "$codev_fast_fails" -ge 3 ]; then + echo "Agent failing immediately; starting a fresh conversation with the original prompt in 2 seconds..." + codev_relaunch_fresh; codev_fast_fails=0 + elif [ "$codev_launch" = codev_launch_unpinned ]; then + echo "Agent exited (code $status). Restarting in 2 seconds..." # no id to resume + else + echo "Agent exited (code $status). Resuming the conversation in 2 seconds... (Ctrl+C to quit)" + codev_launch=codev_launch_resume + fi + sleep 2 +done +``` + +`afx reset`'s `harnessFromLaunchScript` (reset/context.ts:401) detects the harness by command-position scan per line; `claude` stays at command position inside the functions (as it already does in the #1267 two-function variant), so reset keeps working — covered by a test. + +## Files to Change + +- `packages/codev/src/agent-farm/utils/harness.ts:79-92` — add `newSessionScriptFragment` / `resumeScriptFragment` to the `session` seam; implement on `CLAUDE_HARNESS` (`:157-163`). Other harnesses untouched. +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts:803-862` — extend the loop builder: session-aware variant (state machine above) alongside the existing `buildLaunchLoop` (kept verbatim for session-less harnesses); `launchLoopTail` reworked accordingly. +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts:880-969` (`startBuilderSession`) — mint `crypto.randomUUID()` when the harness has script-form session support; write `.builder-session-id`; generate the session-aware script on both the fresh and resume-entry paths. +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts:995-1033` (`buildWorktreeLaunchScript`) — same session-aware loop for worktree-mode spawns (no prompt file; pinned fresh is the role-injected interactive invocation). +- `packages/codev/src/agent-farm/__tests__/` — new executed-loop tests (pattern of `bugfix-1267-launch-loop.test.ts`: real bash + fake agent scripting exit codes) + harness unit tests; see Test Plan. +- Mirror check: nothing in `codev-skeleton/` documents the wrapper's loop mechanics (it's generated code), but I will grep both trees for `.builder-start.sh` / restart-loop mentions and update any docs that describe the fresh-respawn behavior (e.g. builder role docs describing "Tower's while-true loop will relaunch you with the same prompt" — that wording changes). + +## Risks & Alternatives Considered + +- **Risk: `claude --resume "prompt"` might not accept a positional prompt.** Mitigation: verify empirically first (memory lesson: check CLI flags via `--help`/trial before coding). Fallback: resume without the nudge — context preserved, builder idles until poked; still strictly better than amnesia, and `afx interrupt`/`afx send` can wake it. +- **Risk: `--session-id` collision at first launch** (id somehow taken). Fresh random mint per spawn makes this negligible; if it happens, the launch fast-fails and the bounded fallback re-mints — self-healing. +- **Risk: uuidgen unavailable on some Linux.** Fallback chain ends in unpinned fresh = exactly today's behavior; no new failure mode. +- **Risk: jetsam kills the *resumed* process quickly and repeatedly** (memory pressure persists) → after 3 fast deaths we replay the prompt fresh. That's the correct degradation: identical to today's behavior, and #1227 addresses the pressure itself. +- **Risk: breaking `afx reset` / `modeFromBuilderPrompt`.** The prompt file is still written once at spawn and never rewritten (#1267 invariant preserved); harness detection still finds `claude` at command position. Both covered by tests. +- **Alternative: reverse #1267 and make the clean-exit relaunch resume** (architect's lean). Rejected above (§4) — flips freshly shipped, deliberate semantics and reintroduces the complaint that motivated #1267; continuity-after-quit is already served by `--resume`/recover. +- **Alternative: put the whole state machine in Node** (shellper-style CrashLoopFallback for builders). Rejected: builders are PTY-launched bash scripts by design (persistent across Tower restarts); moving restart logic into Tower would couple builder liveness to Tower liveness — a much larger architectural change. +- **Alternative: generic `argsToScriptFragment(args)` helper instead of new seam methods.** Rejected: the seam's dual-form convention (`buildResume`, `buildScriptRoleInjection`) keeps flag shape *and* escaping owned by the provider; a generic escaper would be a second convention. +- **Alternative: absorb #1112 (DB-persisted builder session ids + consumption).** Rejected per architect guidance: this PR writes `.builder-session-id` as the accurate current-id surface and leaves storage/consumption decisions to #1112. + +## Test Plan + +Executed-loop tests (extending the `bugfix-1267-launch-loop.test.ts` harness — real bash, fake agent with scripted exit codes, argv log). **Layer caution (#1244 finding): the wrapper sees bash's 128+N for signal deaths, while node-pty reports `{exitCode: 0, signal: 9}` — these tests run real bash and assert wrapper-layer codes only; no node-pty fixtures.** Fast-fail threshold driven via `CODEV_LAUNCH_FAST_FAIL_SECS` so tests stay fast. + +- Unit: crash (exit 137) → second invocation is `--resume `, NOT the prompt replay; first invocation carried `--session-id ` and the prompt as a single argument. +- Unit: clean exit → Enter → relaunch is pinned fresh with a *different* id, carries role + prompt; a subsequent crash resumes the *new* id (sticky one-way switch, #1267 invariant). +- Unit: resume fast-fails 3× → falls back to pinned fresh (prompt replay) with a new id; `.builder-session-id` reflects each re-mint. +- Unit: slow failure (fake agent sleeps past threshold) resets the fast-fail counter. +- Unit: session-less harness (codex/gemini/custom) → generated script byte-identical to current output (string assertion) and behaviorally unchanged (executed). +- Unit: resume-entry (recover) variant → enters on discovered-id resume without nudge; crash → resume same id with nudge; clean exit → pinned fresh new id. +- Unit: `harnessFromLaunchScript` still detects `claude` from the new script shape; prompt file untouched on resume. +- Unit (harness.ts): Claude's `newSessionScriptFragment`/`resumeScriptFragment` render the expected flags around a caller-supplied id expression. +- Manual (dev-approval gate): + 1. Spawn a scratch builder; `kill -9` its claude pid → observe "Resuming the conversation in 2 seconds…" and the builder waking with context intact (it should re-orient via the nudge). + 2. Double-Ctrl+C then Enter → fresh session, new id in `.builder-session-id`; then `kill -9` → resumes the *new* conversation. + 3. Delete the session jsonl under `~/.claude/projects//`, `kill -9` → three fast resume attempts, then fresh prompt-replay relaunch. + 4. Spawn with a codex builder command (or inspect the generated script) → unchanged loop. + 5. `afx reset` against the new script → still identifies the claude harness. diff --git a/codev/state/pir-1233_thread.md b/codev/state/pir-1233_thread.md new file mode 100644 index 000000000..280fc52cc --- /dev/null +++ b/codev/state/pir-1233_thread.md @@ -0,0 +1,13 @@ +# Thread: pir-1233 — builder crash-restart loses context + +## Plan phase (2026-08-05) + +Investigated the post-#1244/#1267 launch-loop code. Key findings that shaped the plan: + +- The issue body and even the 2026-07-27 re-triage comment are both partially stale: #1267 has since restructured `buildLaunchLoop(initial, fresh)` with a sticky clean-exit→fresh switch. The amnesia path (nonzero exit → rerun prompt-carrying fresh invocation) is still fully present on fresh spawns; my own worktree's `.builder-start.sh` is a live specimen. +- The architect side (#832/#1145/#1149/#1224/#1264, `tower-utils.ts:340-529`) already implements the complete target pattern: mint+pin, ownership check, bounded crash-loop fallback, fresh-with-new-id on clean exit. Builders need the same state machine expressed in generated bash. +- Design decision argued in the plan (architect asked for an explicit argument, leaning resume): keep the clean-exit relaunch FRESH per shipped #1267/#1264 semantics, but pin it to a newly minted id so post-relaunch crashes are also protected. +- Discovered a subtlety not in the issue: `claude --resume ` without a prompt restores context but leaves an unattended builder idle. Plan adds a crash-resume nudge prompt (empirical verification of `--resume ""` scheduled before implementation). +- Scope coordination: #1112 (persisted builder session ids) gets `.builder-session-id` written by the wrapper as its accurate current-id surface; storage/consumption stays out of this PR. + +Plan written to `codev/plans/1233-builder-crash-restart-loses-al.md`; sitting at plan-approval gate. From cd040a8f00230c01e2bc32d1b8c171ee6694b4f2 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 18:53:20 +1000 Subject: [PATCH 03/24] chore(porch): 1233 plan-approval gate-requested --- codev/projects/1233-builder-crash-restart-loses-al/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index cab68cd70..3491ca3e4 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -7,6 +7,7 @@ current_plan_phase: null gates: plan-approval: status: pending + requested_at: '2026-08-05T08:53:20.352Z' dev-approval: status: pending pr: @@ -15,4 +16,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T08:47:09.608Z' +updated_at: '2026-08-05T08:53:20.353Z' From eda100ff16ed32c47fef65952e83172d8940ad06 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 18:58:53 +1000 Subject: [PATCH 04/24] chore(porch): 1233 plan-approval gate-approved --- .../projects/1233-builder-crash-restart-loses-al/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index 3491ca3e4..b349a4a3e 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -6,8 +6,9 @@ plan_phases: [] current_plan_phase: null gates: plan-approval: - status: pending + status: approved requested_at: '2026-08-05T08:53:20.352Z' + approved_at: '2026-08-05T08:58:53.377Z' dev-approval: status: pending pr: @@ -16,4 +17,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T08:53:20.353Z' +updated_at: '2026-08-05T08:58:53.378Z' From 1b46ba048d2b922d7cdcccb05e81ab01c2da19ce Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 18:59:02 +1000 Subject: [PATCH 05/24] chore(porch): 1233 implement phase-transition --- .../projects/1233-builder-crash-restart-loses-al/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index b349a4a3e..8b2b01a12 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -1,7 +1,7 @@ id: '1233' title: builder-crash-restart-loses-al protocol: pir -phase: plan +phase: implement plan_phases: [] current_plan_phase: null gates: @@ -17,4 +17,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T08:58:53.378Z' +updated_at: '2026-08-05T08:59:02.251Z' From 8e4156c27336ee1c8dd7a5f9000e75674351c678 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:06:32 +1000 Subject: [PATCH 06/24] [PIR #1233] Crash restarts resume the pinned session instead of respawning fresh --- .../src/agent-farm/commands/spawn-worktree.ts | 238 ++++++++++++++++-- .../codev/src/agent-farm/utils/harness.ts | 20 ++ 2 files changed, 235 insertions(+), 23 deletions(-) diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index 2f08133b7..0369004b5 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -835,6 +835,11 @@ function launchLoopTail(onCleanExit?: string): string { * The two commands differ only on the resume path. Every other variant passes * the same string twice and gets the historical single-command loop back, * byte for byte. + * + * Issue #1233: this loop now serves only harnesses WITHOUT script-form session + * support (codex/gemini/opencode/custom) — their generated script is unchanged, + * byte for byte. Claude builders get `buildSessionLaunchLoop` instead, which + * resumes the conversation on crash restarts rather than replaying the prompt. */ export function buildLaunchLoop(initial: string, fresh: string): string { if (initial === fresh) { @@ -861,6 +866,158 @@ done `; } +/** + * The prompt a crash-resume invocation carries (Issue #1233). Without it, + * `--resume` restores the conversation but leaves the agent idle at the input + * prompt — for an unattended builder that converts amnesia into a stall. The + * nudge is a new user message in the restored conversation, so the builder + * re-orients itself and continues autonomously. Deliberately free of + * single quotes: it is embedded single-quoted in the generated script. + */ +export const CRASH_RESUME_NUDGE = + 'You were automatically restarted after a crash. Your prior conversation context has been restored. ' + + 'Re-orient yourself (in strict mode run porch next for your project; check queued afx messages) ' + + 'and continue your work from where you left off.'; + +/** The shell expression the session-aware loop's commands use to reference the current session id. */ +export const SESSION_ID_EXPR = '"$codev_session_id"'; + +/** + * Script-form session support for a harness, or undefined when the harness + * cannot pin/resume a conversation from a generated bash script. Both fragment + * forms are required — the session-aware loop needs to pin AND resume. + */ +export function scriptSessionForms(harness: HarnessProvider): { + newSessionScriptFragment(idExpr: string): string; + resumeScriptFragment(idExpr: string): string; +} | undefined { + const session = harness.session; + if (!session?.newSessionScriptFragment || !session?.resumeScriptFragment) return undefined; + return { + newSessionScriptFragment: session.newSessionScriptFragment.bind(session), + resumeScriptFragment: session.resumeScriptFragment.bind(session), + }; +} + +/** + * Build the session-aware launch loop (Issue #1233) — the builder-side + * expression of the architect resume pattern (#832/#1264): the wrapper pins a + * conversation id at entry and *resumes* it after an unnatural exit, instead of + * replaying the spawn prompt into a fresh session (the amnesia path this issue + * removes). A jetsam SIGKILL now costs 2 seconds, not the builder's memory. + * + * State machine, expressed in generated bash because builder liveness is + * deliberately decoupled from Tower (persistent PTYs survive Tower restarts, + * so no Node process is guaranteed to be around when the crash fires): + * + * - Entry: `initial` if given (the recover path's discovered-id resume), else + * the pinned fresh invocation (`--session-id` + role + prompt). + * - Unnatural exit → resume `$codev_session_id` with a short nudge prompt, so + * the restored builder re-orients and continues rather than idling. + * - Clean exit → Enter-gated relaunch stays FRESH per #1267/#1264 ("a clean + * exit ends that conversation"), but pinned to a newly minted id so the new + * conversation is crash-protected too. Sticky and one-way, like #1267. + * - Unresumable-session degrade (#1145/#1149 lesson): `--resume` against a + * gone/corrupt/held session dies fast; three consecutive fast failures + * (< CODEV_LAUNCH_FAST_FAIL_SECS, default 15) fall back to a prompt-replay + * fresh launch under a new id — today's behavior, crash-protected forward. + * A slow failure resets the counter. + * - Id minting at runtime uses uuidgen (lowercased) or /proc fallback; when + * neither exists the relaunch degrades to the UNPINNED fresh invocation — + * exactly the historical command — and crash restarts stay unpinned rather + * than resuming a stale id. + * + * `.builder-session-id` always holds the current id (removed while unpinned). + * The bash script is the sole writer: after a re-mint it is the only party + * that knows the current id, so Node- or DB-side copies would go stale + * (consumption by recover/--resume is Issue #1112's scope). + */ +export function buildSessionLaunchLoop(opts: { + /** Initial value of $codev_session_id: spawn-minted, or the discovered id on the recover path. */ + sessionId: string; + /** Entry command override (recover path). Defaults to the pinned fresh invocation. */ + initial?: string; + /** Fresh conversation pinned to $codev_session_id: role + pin + prompt. */ + pinnedFresh: string; + /** The historical unpinned fresh invocation — degrade target when no uuid can be minted. */ + unpinnedFresh: string; + /** Resume $codev_session_id with the crash-resume nudge. */ + resume: string; +}): string { + const entry = opts.initial ?? opts.pinnedFresh; + return `codev_session_id='${shellEscapeSingleQuote(opts.sessionId)}' +codev_fast_fail_secs="\${CODEV_LAUNCH_FAST_FAIL_SECS:-15}" +codev_mint_session_id() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen | tr '[:upper:]' '[:lower:]' + elif [ -r /proc/sys/kernel/random/uuid ]; then + cat /proc/sys/kernel/random/uuid + fi +} +codev_persist_session_id() { + printf '%s\\n' "$codev_session_id" > '.builder-session-id' +} +codev_launch_entry() { + ${entry} +} +codev_launch_pinned() { + ${opts.pinnedFresh} +} +codev_launch_unpinned() { + ${opts.unpinnedFresh} +} +codev_launch_resume() { + ${opts.resume} +} +codev_relaunch_fresh() { + codev_new_session_id="$(codev_mint_session_id)" + if [ -n "$codev_new_session_id" ]; then + codev_session_id="$codev_new_session_id" + codev_persist_session_id + codev_launch=codev_launch_pinned + else + codev_session_id='' + rm -f '.builder-session-id' + codev_launch=codev_launch_unpinned + fi +} +codev_launch=codev_launch_entry +codev_fast_fails=0 +codev_persist_session_id +while true; do + codev_started=$SECONDS + "$codev_launch" + status=$? + codev_elapsed=$(( SECONDS - codev_started )) + if [ "$status" -eq 0 ]; then + clear + echo "Agent exited at your request. Press Enter to relaunch fresh, or close this terminal." + read -r || exit 0 + codev_relaunch_fresh + codev_fast_fails=0 + continue + fi + if [ "$codev_elapsed" -lt "$codev_fast_fail_secs" ]; then + codev_fast_fails=$(( codev_fast_fails + 1 )) + else + codev_fast_fails=0 + fi + echo "" + if [ "$codev_fast_fails" -ge 3 ]; then + echo "Agent failing immediately (code $status). Starting a fresh conversation with the original prompt in 2 seconds... (Ctrl+C to quit)" + codev_relaunch_fresh + codev_fast_fails=0 + elif [ "$codev_launch" = codev_launch_unpinned ]; then + echo "Agent exited (code $status). Restarting in 2 seconds... (Ctrl+C to quit)" + else + echo "Agent exited (code $status). Resuming the conversation in 2 seconds... (Ctrl+C to quit)" + codev_launch=codev_launch_resume + fi + sleep 2 +done +`; +} + /** * Start a terminal session for a builder. * @@ -911,7 +1068,7 @@ export async function startBuilderSession( const harness = getBuilderHarness(config.workspaceRoot); let envBlock = ''; - let freshCommand: string; + let roleFragment = ''; if (roleContent) { // Write role to a file for harness-based injection @@ -922,6 +1079,7 @@ export async function startBuilderSession( logger.info(`Loaded role (${roleSource})`); const { fragment, env } = harness.buildScriptRoleInjection(roleWithPort, roleFile); + roleFragment = fragment; const envExports = Object.entries(env) .map(([k, v]) => `export ${k}='${shellEscapeSingleQuote(v)}'`) .join('\n'); @@ -930,27 +1088,48 @@ export async function startBuilderSession( // Write any harness-specific worktree files (e.g., opencode.json for OpenCode, // the write-guard hook for Claude — Issue #1018) installHarnessWorktreeFiles(harness, roleWithPort, roleFile, worktreePath); - - freshCommand = `${baseCmd} ${fragment} "$(cat '${promptFile}')"`; } else { // Install harness worktree files even without a role, so the write-guard // (Issue #1018) is deterministic across all Claude spawn modes. installHarnessWorktreeFiles(harness, '', '', worktreePath); - - freshCommand = `${baseCmd} "$(cat '${promptFile}')"`; } - let initialCommand = freshCommand; + // With a role, the fragment is appended even when empty (gemini injects via + // env only, fragment '') — preserving the historical command text exactly, + // double space included, so session-less scripts stay byte-identical. + const withRole = roleContent ? `${baseCmd} ${roleFragment}` : baseCmd; + const promptArg = `"$(cat '${promptFile}')"`; + const freshCommand = `${withRole} ${promptArg}`; + if (resume) { - // Resume path: enter on the prior conversation via the harness-provided, - // shell-escaped resume fragment. logger.info(`Resuming session ${resume.sessionId.slice(0, 8)}…`); - initialCommand = `${baseCmd} ${resume.scriptFragment}`; + } + + const sessionForms = scriptSessionForms(harness); + let loop: string; + if (sessionForms) { + // Issue #1233: session-aware loop — crash restarts resume the conversation + // instead of replaying the spawn prompt into a fresh (amnesiac) session. + // Fresh spawns mint a new id here (never reuse a persisted one — #1224); + // the recover path enters on the harness-discovered id. + const sessionId = resume ? resume.sessionId : randomUUID(); + loop = buildSessionLaunchLoop({ + sessionId, + initial: resume ? `${baseCmd} ${resume.scriptFragment}` : undefined, + pinnedFresh: `${withRole} ${sessionForms.newSessionScriptFragment(SESSION_ID_EXPR)} ${promptArg}`, + unpinnedFresh: freshCommand, + resume: `${baseCmd} ${sessionForms.resumeScriptFragment(SESSION_ID_EXPR)} '${shellEscapeSingleQuote(CRASH_RESUME_NUDGE)}'`, + }); + } else { + // Session-less harness (codex/gemini/opencode/custom): historical loop, + // byte for byte. Resume entry via the pre-escaped harness fragment. + const initialCommand = resume ? `${baseCmd} ${resume.scriptFragment}` : freshCommand; + loop = buildLaunchLoop(initialCommand, freshCommand); } const scriptContent = `#!/bin/bash cd "${worktreePath}" -${envBlock}${buildLaunchLoop(initialCommand, freshCommand)}`; +${envBlock}${loop}`; writeFileSync(scriptPath, scriptContent); chmodSync(scriptPath, '755'); @@ -998,35 +1177,48 @@ export function buildWorktreeLaunchScript( role: { content: string; source: string } | null, workspaceRoot?: string, ): string { + const harness = getBuilderHarness(workspaceRoot); + let envBlock = ''; + let command = baseCmd; + if (role) { const roleFile = resolve(worktreePath, '.builder-role.md'); const roleWithPort = role.content.replace(/\{PORT\}/g, String(DEFAULT_TOWER_PORT)); writeFileSync(roleFile, roleWithPort); logger.info(`Loaded role (${role.source})`); - // Resolve harness provider for role injection - const harness = getBuilderHarness(workspaceRoot); const { fragment, env } = harness.buildScriptRoleInjection(roleWithPort, roleFile); const envExports = Object.entries(env) .map(([k, v]) => `export ${k}='${shellEscapeSingleQuote(v)}'`) .join('\n'); - const envBlock = envExports ? `${envExports}\n` : ''; + envBlock = envExports ? `${envExports}\n` : ''; // Write any harness-specific worktree files (e.g., opencode.json for OpenCode, // the write-guard hook for Claude — Issue #1018) installHarnessWorktreeFiles(harness, roleWithPort, roleFile, worktreePath); - // Worktree mode never resumes, so entry and clean-exit relaunch are the - // same invocation — `buildLaunchLoop` collapses to the single-command loop. - const command = `${baseCmd} ${fragment}`; - return `#!/bin/bash -cd "${worktreePath}" -${envBlock}${buildLaunchLoop(command, command)}`; + command = `${baseCmd} ${fragment}`; + } else { + // Install harness worktree files even without a role, so the write-guard + // (Issue #1018) is deterministic across all Claude spawn modes. + installHarnessWorktreeFiles(harness, '', '', worktreePath); } - // Install harness worktree files even without a role, so the write-guard - // (Issue #1018) is deterministic across all Claude spawn modes. - installHarnessWorktreeFiles(getBuilderHarness(workspaceRoot), '', '', worktreePath); + + // Worktree mode never enters on a resume, but the loop itself is + // session-aware when the harness supports it (Issue #1233): crash restarts + // resume the pinned conversation here too. There is no prompt file in this + // mode, so the fresh and degraded invocations are the bare command. + const sessionForms = scriptSessionForms(harness); + const loop = sessionForms + ? buildSessionLaunchLoop({ + sessionId: randomUUID(), + pinnedFresh: `${command} ${sessionForms.newSessionScriptFragment(SESSION_ID_EXPR)}`, + unpinnedFresh: command, + resume: `${baseCmd} ${sessionForms.resumeScriptFragment(SESSION_ID_EXPR)} '${shellEscapeSingleQuote(CRASH_RESUME_NUDGE)}'`, + }) + : buildLaunchLoop(command, command); + return `#!/bin/bash cd "${worktreePath}" -${buildLaunchLoop(baseCmd, baseCmd)}`; +${envBlock}${loop}`; } diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index fb7f438be..c8f9f351d 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -81,6 +81,22 @@ export interface HarnessProvider { newSessionArgs(sessionId: string): string[]; /** Args to RESUME an existing session by id (caller skips role injection). */ resumeArgs(sessionId: string): string[]; + /** + * Optional: script-fragment forms of newSessionArgs/resumeArgs for bash + * script generation (the builder launch loop — Issue #1233), mirroring the + * dual-form convention of buildRoleInjection/buildScriptRoleInjection and + * buildResume's args/scriptFragment pair. + * + * `idExpr` is a shell expression the caller has already quoted (e.g. + * `"$codev_session_id"`), NOT a literal id: the generated loop re-mints ids + * at runtime (clean-exit relaunch, unresumable-session degrade), so the + * fragment must reference the script's variable rather than bake a value. + * + * BOTH must be present for the session-aware loop; a harness providing + * neither keeps the historical prompt-replay restart loop. + */ + newSessionScriptFragment?(idExpr: string): string; + resumeScriptFragment?(idExpr: string): string; /** * Optional: verify that `sessionId` still has a resumable session on disk * for `cwd` before the caller resumes it (Issue #1145). Returns false when @@ -157,6 +173,10 @@ export const CLAUDE_HARNESS: HarnessProvider = { session: { newSessionArgs: (sessionId) => ['--session-id', sessionId], resumeArgs: (sessionId) => ['--resume', sessionId], + // Issue #1233: script-fragment forms for the builder crash-resume loop. + // `idExpr` arrives pre-quoted (a shell variable reference, not a literal). + newSessionScriptFragment: (idExpr) => `--session-id ${idExpr}`, + resumeScriptFragment: (idExpr) => `--resume ${idExpr}`, // Issue #1145: a stored id is only resumed when its jsonl still exists // under this cwd's project dir (stale ids degrade to a fresh spawn). verifyOwnership: (sessionId, cwd, opts) => verifySessionOwnership(cwd, sessionId, opts), From 11e788c8fdc5697f2bc55ff2d977f021ceb9f1b1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:06:32 +1000 Subject: [PATCH 07/24] [PIR #1233] docs: crash-restart now resumes; update PIR loop descriptions in both trees --- codev-skeleton/protocols/pir/builder-prompt.md | 2 +- codev-skeleton/protocols/pir/protocol.md | 2 +- codev/protocols/pir/builder-prompt.md | 2 +- codev/protocols/pir/protocol.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codev-skeleton/protocols/pir/builder-prompt.md b/codev-skeleton/protocols/pir/builder-prompt.md index 86016f310..6de1b7b89 100644 --- a/codev-skeleton/protocols/pir/builder-prompt.md +++ b/codev-skeleton/protocols/pir/builder-prompt.md @@ -78,7 +78,7 @@ If you encounter **pre-existing flaky tests** (intermittent failures unrelated t ## Resumption After Crash -If your Claude session crashes mid-flow, Tower's `while true` loop will relaunch you with the same prompt. On startup: +If your Claude session crashes mid-flow, Tower's launch loop **resumes your conversation** (`--resume` against the session id pinned at spawn) and sends you a short re-orientation nudge — your context is intact; re-check state and continue. Only when the session is unresumable (repeated fast failures) does the loop fall back to relaunching fresh with the same spawn prompt. On a fresh relaunch: 1. Run `porch next {{project_id}}` to learn what phase you're in 2. If `gate_pending`: read the latest plan file (plan-approval) or `DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||'); git diff "$(git merge-base "${DEFAULT_BRANCH:-main}" HEAD)"` (dev-approval) plus any new GitHub issue comments; check `afx send` queue. Decide whether to revise or just announce you're back. diff --git a/codev-skeleton/protocols/pir/protocol.md b/codev-skeleton/protocols/pir/protocol.md index b3befc173..ec1ad7afe 100644 --- a/codev-skeleton/protocols/pir/protocol.md +++ b/codev-skeleton/protocols/pir/protocol.md @@ -128,7 +128,7 @@ The same pattern works at both gates. ## Builder Session Lifetime -The builder is a long-running interactive Claude Code session in a PTY pane managed by Tower. The session is launched as `claude ""` (no `--print`) inside a `while true` restart loop. That form starts an interactive Claude REPL with the prompt as the first user message; after Claude finishes the prompted work it sits at the input prompt awaiting next user input. The outer `while true` loop only fires if Claude crashes — it is a crash-recovery safety net, not the gate-wait mechanism. +The builder is a long-running interactive Claude Code session in a PTY pane managed by Tower. The session is launched as `claude ""` (no `--print`) inside a restart loop, pinned to a session id minted at spawn. That form starts an interactive Claude REPL with the prompt as the first user message; after Claude finishes the prompted work it sits at the input prompt awaiting next user input. The outer loop only fires if Claude crashes — it is a crash-recovery safety net, not the gate-wait mechanism — and it **resumes the pinned conversation** (context intact, plus a re-orientation nudge) rather than replaying the prompt; an unresumable session degrades to a fresh prompt-replay relaunch after repeated fast failures. A deliberate quit (clean exit) gates on Enter and relaunches a fresh conversation. This means typed input in the builder pane reaches the live Claude session immediately, exactly like any other interactive Claude Code conversation. There is no "session ended at gate" state to worry about under normal operation. diff --git a/codev/protocols/pir/builder-prompt.md b/codev/protocols/pir/builder-prompt.md index 86016f310..6de1b7b89 100644 --- a/codev/protocols/pir/builder-prompt.md +++ b/codev/protocols/pir/builder-prompt.md @@ -78,7 +78,7 @@ If you encounter **pre-existing flaky tests** (intermittent failures unrelated t ## Resumption After Crash -If your Claude session crashes mid-flow, Tower's `while true` loop will relaunch you with the same prompt. On startup: +If your Claude session crashes mid-flow, Tower's launch loop **resumes your conversation** (`--resume` against the session id pinned at spawn) and sends you a short re-orientation nudge — your context is intact; re-check state and continue. Only when the session is unresumable (repeated fast failures) does the loop fall back to relaunching fresh with the same spawn prompt. On a fresh relaunch: 1. Run `porch next {{project_id}}` to learn what phase you're in 2. If `gate_pending`: read the latest plan file (plan-approval) or `DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||'); git diff "$(git merge-base "${DEFAULT_BRANCH:-main}" HEAD)"` (dev-approval) plus any new GitHub issue comments; check `afx send` queue. Decide whether to revise or just announce you're back. diff --git a/codev/protocols/pir/protocol.md b/codev/protocols/pir/protocol.md index b3befc173..ec1ad7afe 100644 --- a/codev/protocols/pir/protocol.md +++ b/codev/protocols/pir/protocol.md @@ -128,7 +128,7 @@ The same pattern works at both gates. ## Builder Session Lifetime -The builder is a long-running interactive Claude Code session in a PTY pane managed by Tower. The session is launched as `claude ""` (no `--print`) inside a `while true` restart loop. That form starts an interactive Claude REPL with the prompt as the first user message; after Claude finishes the prompted work it sits at the input prompt awaiting next user input. The outer `while true` loop only fires if Claude crashes — it is a crash-recovery safety net, not the gate-wait mechanism. +The builder is a long-running interactive Claude Code session in a PTY pane managed by Tower. The session is launched as `claude ""` (no `--print`) inside a restart loop, pinned to a session id minted at spawn. That form starts an interactive Claude REPL with the prompt as the first user message; after Claude finishes the prompted work it sits at the input prompt awaiting next user input. The outer loop only fires if Claude crashes — it is a crash-recovery safety net, not the gate-wait mechanism — and it **resumes the pinned conversation** (context intact, plus a re-orientation nudge) rather than replaying the prompt; an unresumable session degrades to a fresh prompt-replay relaunch after repeated fast failures. A deliberate quit (clean exit) gates on Enter and relaunches a fresh conversation. This means typed input in the builder pane reaches the live Claude session immediately, exactly like any other interactive Claude Code conversation. There is no "session ended at gate" state to worry about under normal operation. From d158f2fdebfa8d903e89bf013aa449be4cf9cbf2 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:06:44 +1000 Subject: [PATCH 08/24] [PIR #1233] Test: execute the session-aware loop and assert resume/degrade/re-mint behavior --- .../pir-1233-session-launch-loop.test.ts | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts diff --git a/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts b/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts new file mode 100644 index 000000000..651e7da8e --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts @@ -0,0 +1,241 @@ +/** + * PIR #1233 — the builder launch loop must RESUME the conversation after a + * crash instead of replaying the spawn prompt into a fresh, amnesiac session. + * + * Like the #1267 suite, these tests execute the generated loop under real bash + * with a fake agent that scripts an exact exit-code sequence, then assert on + * the argv log — the loop is generated bash and its bugs are bash-level bugs. + * + * Layer caution (#1244 finding): the wrapper sees bash's 128+N for signal + * deaths, while node-pty reports {exitCode: 0, signal}. Everything here runs + * real bash and asserts wrapper-layer codes only; 137 below IS "SIGKILLed" as + * the wrapper perceives it. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, chmodSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + buildSessionLaunchLoop, + buildLaunchLoop, + scriptSessionForms, + CRASH_RESUME_NUDGE, + SESSION_ID_EXPR, +} from '../commands/spawn-worktree.js'; +import { CLAUDE_HARNESS, CODEX_HARNESS, GEMINI_HARNESS, OPENCODE_HARNESS } from '../utils/harness.js'; +import { harnessFromLaunchScript } from '../commands/reset/context.js'; + +const SPAWN_ID = 'aaaaaaaa-1111-2222-3333-444444444444'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +let dir: string; + +/** Same fake-agent contract as the #1267 suite: argv appended to argv.log + * (one `|`-separated line per invocation, from `"$@"` so argument boundaries + * are observable), exit code scripted per invocation via `codes`. */ +function writeFakeAgent(exitCodes: number[]): string { + const agent = join(dir, 'fake-agent'); + writeFileSync( + agent, + `#!/bin/bash +{ printf '%s|' "$@"; printf '\\n'; } >> '${dir}/argv.log' +n=$(cat '${dir}/count') +echo $((n + 1)) > '${dir}/count' +code=$(sed -n "$((n + 1))p" '${dir}/codes') +exit "\${code:-0}" +`, + ); + chmodSync(agent, '755'); + writeFileSync(join(dir, 'count'), '0\n'); + writeFileSync(join(dir, 'codes'), exitCodes.join('\n') + '\n'); + return agent; +} + +/** Build the session-aware loop the way startBuilderSession does for Claude, + * but around the fake agent. Mirrors production command construction: + * pinned = role-ish + pin + prompt, resume = bare + resume + nudge. */ +function buildLoop(agent: string, opts?: { initial?: string; sessionId?: string }): string { + const forms = scriptSessionForms(CLAUDE_HARNESS)!; + const promptArg = `"$(cat '${dir}/prompt.txt')"`; + return buildSessionLaunchLoop({ + sessionId: opts?.sessionId ?? SPAWN_ID, + initial: opts?.initial, + pinnedFresh: `'${agent}' ${forms.newSessionScriptFragment(SESSION_ID_EXPR)} ${promptArg}`, + unpinnedFresh: `'${agent}' ${promptArg}`, + resume: `'${agent}' ${forms.resumeScriptFragment(SESSION_ID_EXPR)} '${CRASH_RESUME_NUDGE}'`, + }); +} + +function runLoop(loop: string, enterPresses: number, env?: Record): string[] { + const script = join(dir, 'start.sh'); + writeFileSync(script, `#!/bin/bash\ncd '${dir}'\n${loop}`); + chmodSync(script, '755'); + execFileSync('bash', [script], { + input: '\n'.repeat(enterPresses), + stdio: ['pipe', 'ignore', 'ignore'], + timeout: 60_000, + env: { ...process.env, TERM: 'dumb', ...env }, + }); + const log = join(dir, 'argv.log'); + if (!existsSync(log)) return []; + return readFileSync(log, 'utf-8').split('\n').filter(Boolean); +} + +const argvOf = (line: string): string[] => line.split('|').filter(Boolean); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'codev-1233-')); + writeFileSync(join(dir, 'prompt.txt'), 'the spawn prompt'); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('PIR #1233 — crash restarts resume the conversation', () => { + it('resumes the spawn-pinned session after an unnatural exit, with the nudge, instead of replaying the prompt', () => { + const agent = writeFakeAgent([137, 0]); + const invocations = runLoop(buildLoop(agent), 0); + + expect(invocations).toHaveLength(2); + // First launch: pinned fresh, prompt as a SINGLE argument. + expect(argvOf(invocations[0])).toEqual(['--session-id', SPAWN_ID, 'the spawn prompt']); + // Crash restart: resume of the SAME id + nudge — no role, no prompt replay. + expect(argvOf(invocations[1])).toEqual(['--resume', SPAWN_ID, CRASH_RESUME_NUDGE]); + }); + + it('persists the current session id to .builder-session-id', () => { + const agent = writeFakeAgent([0]); + runLoop(buildLoop(agent), 0); + expect(readFileSync(join(dir, '.builder-session-id'), 'utf-8').trim()).toBe(SPAWN_ID); + }); + + it('relaunches FRESH after a clean exit (per #1267) but pinned to a newly minted id', () => { + const agent = writeFakeAgent([0, 0]); + const invocations = runLoop(buildLoop(agent), 1); + + expect(invocations).toHaveLength(2); + const second = argvOf(invocations[1]); + expect(second[0]).toBe('--session-id'); + expect(second[1]).not.toBe(SPAWN_ID); + expect(second[1]).toMatch(UUID_RE); + // Fresh means the prompt is replayed — a new conversation, not a resume. + expect(second[2]).toBe('the spawn prompt'); + // The re-mint is persisted. + expect(readFileSync(join(dir, '.builder-session-id'), 'utf-8').trim()).toBe(second[1]); + }); + + it('a crash after a clean-exit relaunch resumes the NEW conversation, never the superseded one', () => { + const agent = writeFakeAgent([0, 137, 0]); + const invocations = runLoop(buildLoop(agent), 1); + + expect(invocations).toHaveLength(3); + const relaunchId = argvOf(invocations[1])[1]; + expect(relaunchId).not.toBe(SPAWN_ID); + expect(argvOf(invocations[2])).toEqual(['--resume', relaunchId, CRASH_RESUME_NUDGE]); + }, 15_000); + + it('degrades to a prompt-replay fresh launch under a new id after 3 consecutive fast resume failures', () => { + // Fake agent exits instantly, so every failure is "fast" under the default + // threshold: crash (1 fast fail) → resume (2) → resume (3 → degrade) → + // pinned fresh with a NEW id and the prompt. + const agent = writeFakeAgent([137, 1, 1, 0]); + const invocations = runLoop(buildLoop(agent), 0); + + expect(invocations).toHaveLength(4); + expect(argvOf(invocations[1])[0]).toBe('--resume'); + expect(argvOf(invocations[2])[0]).toBe('--resume'); + const fallback = argvOf(invocations[3]); + expect(fallback[0]).toBe('--session-id'); + expect(fallback[1]).not.toBe(SPAWN_ID); + expect(fallback[2]).toBe('the spawn prompt'); + }, 30_000); // real `sleep 2` between restarts + + it('failures slower than the threshold never trip the degrade fallback', () => { + // CODEV_LAUNCH_FAST_FAIL_SECS=0 makes NO failure count as fast (elapsed < 0 + // is impossible), so even 4 consecutive crashes keep resuming — proving the + // fallback is gated on the threshold, not on failure count alone. + const agent = writeFakeAgent([137, 137, 137, 137, 0]); + const invocations = runLoop(buildLoop(agent), 0, { CODEV_LAUNCH_FAST_FAIL_SECS: '0' }); + + expect(invocations).toHaveLength(5); + for (const line of invocations.slice(1)) { + expect(argvOf(line)).toEqual(['--resume', SPAWN_ID, CRASH_RESUME_NUDGE]); + } + }, 30_000); // real `sleep 2` between restarts + + it('recover-path entry uses the provided initial command; a crash then resumes the discovered id with the nudge', () => { + const agent = writeFakeAgent([137, 0]); + const discovered = 'dddddddd-5555-6666-7777-888888888888'; + const loop = buildLoop(agent, { + sessionId: discovered, + initial: `'${agent}' --resume '${discovered}'`, + }); + const invocations = runLoop(loop, 0); + + expect(invocations).toHaveLength(2); + // Entry: the harness-discovered resume, no nudge (a human drives recover). + expect(argvOf(invocations[0])).toEqual(['--resume', discovered]); + // Crash restart: same conversation, now with the nudge. + expect(argvOf(invocations[1])).toEqual(['--resume', discovered, CRASH_RESUME_NUDGE]); + }); + + it('EOF on stdin at the clean-exit gate exits without re-minting', () => { + const agent = writeFakeAgent([0]); + runLoop(buildLoop(agent), 0); + // The spawn id — not a re-mint — is what remains persisted. + expect(readFileSync(join(dir, '.builder-session-id'), 'utf-8').trim()).toBe(SPAWN_ID); + }); +}); + +describe('PIR #1233 — harness gating', () => { + it('only the Claude harness offers script-form session support', () => { + expect(scriptSessionForms(CLAUDE_HARNESS)).toBeDefined(); + expect(scriptSessionForms(CODEX_HARNESS)).toBeUndefined(); + expect(scriptSessionForms(GEMINI_HARNESS)).toBeUndefined(); + expect(scriptSessionForms(OPENCODE_HARNESS)).toBeUndefined(); + }); + + it('claude renders pin/resume fragments around the caller-supplied id expression', () => { + const forms = scriptSessionForms(CLAUDE_HARNESS)!; + expect(forms.newSessionScriptFragment('"$codev_session_id"')).toBe('--session-id "$codev_session_id"'); + expect(forms.resumeScriptFragment('"$codev_session_id"')).toBe('--resume "$codev_session_id"'); + }); + + it('session-less harnesses keep the historical single-command loop, byte for byte', () => { + const command = `'/usr/bin/codex' -c model_instructions_file='/w/.builder-role.md'`; + expect(buildLaunchLoop(command, command)).toBe(`while true; do + ${command} + status=$? + if [ "$status" -eq 0 ]; then + clear + echo "Agent exited at your request. Press Enter to relaunch fresh, or close this terminal." + read -r || exit 0 + continue + fi + echo "" + echo "Agent exited (code $status). Restarting in 2 seconds... (Ctrl+C to quit)" + sleep 2 +done +`); + }); +}); + +describe('PIR #1233 — downstream consumers of the generated script', () => { + it('afx reset still identifies the claude harness from the session-aware script', () => { + const loop = buildLoop('claude'); + const script = `#!/bin/bash\ncd '${dir}'\n${loop}`; + const fs = { + exists: () => true, + read: (path: string) => (path.endsWith('.builder-start.sh') ? script : null), + listDirs: () => null, + }; + expect(harnessFromLaunchScript(fs, dir)).toBe('claude'); + }); + + it('the nudge prompt contains no single quotes (it is embedded single-quoted in bash)', () => { + expect(CRASH_RESUME_NUDGE).not.toContain("'"); + }); +}); From 05184d2e09c885a05c487611bbcc7cee7903541d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:07:10 +1000 Subject: [PATCH 09/24] [PIR #1233] thread: implement-phase notes --- codev/state/pir-1233_thread.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/codev/state/pir-1233_thread.md b/codev/state/pir-1233_thread.md index 280fc52cc..fb8a64eb9 100644 --- a/codev/state/pir-1233_thread.md +++ b/codev/state/pir-1233_thread.md @@ -11,3 +11,15 @@ Investigated the post-#1244/#1267 launch-loop code. Key findings that shaped the - Scope coordination: #1112 (persisted builder session ids) gets `.builder-session-id` written by the wrapper as its accurate current-id surface; storage/consumption stays out of this PR. Plan written to `codev/plans/1233-builder-crash-restart-loses-al.md`; sitting at plan-approval gate. + +Gate discussion (recorded for the review): blast radius (every Claude builder spawn; contained by session-less byte-identity, untouched architect path, spawn-time-only script generation), and why `.builder-session-id` is a worktree file rather than a DB row — builders have no session row today (#1112's scope), and after spawn only the bash wrapper knows the current id (re-mints on clean exit / degrade), so DB writes would go stale and bash writing global.db would violate the never-modify-state-by-hand invariant. Also why crash-resume can't reuse recover's mtime discovery: the session to resume doesn't exist yet at spawn time (pin-then-resume is the only way to name it), discovery-in-bash would bake Claude's storage layout into every worktree, and unattended newest-jsonl resume risks hijacking a human's stray session (#1145 lesson). + +## Implement phase (2026-08-05) + +- Empirically verified pin-then-resume round trip: `claude -p --session-id ` then `claude -p --resume ""` restores context AND accepts a positional prompt (codeword test). The crash-resume nudge design is sound. +- Harness seam: added optional `newSessionScriptFragment`/`resumeScriptFragment` to `HarnessProvider.session` (dual-form convention); Claude only. +- `buildSessionLaunchLoop` in spawn-worktree.ts: pin at entry → resume-with-nudge on unnatural exit → 3-consecutive-fast-failures degrade to prompt-replay under a re-minted id → clean exit stays fresh (per #1267) but pinned to a new id. `CODEV_LAUNCH_FAST_FAIL_SECS` (default 15) drives the fast-fail threshold. uuidgen→/proc→unpinned fallback chain for runtime minting. +- Deviation from plan (minor): Node does NOT write `.builder-session-id` at spawn — bash is the sole writer (runs `codev_persist_session_id` before the first launch). One writer beats two writers of the same value; the file exists within milliseconds of PTY start. +- Byte-identity subtlety: kept the historical double-space in role-bearing commands with empty fragments (gemini) so session-less scripts are truly byte-identical. +- 13 new executed-bash tests green (crash→resume, clean-exit re-mint, sticky switch, degrade, threshold gating, recover variant, harness gating, reset detection). +- Docs: PIR builder-prompt + protocol "crash relaunches you with the same prompt" wording updated to resume semantics, mirrored to codev-skeleton (verified byte-identical). From 6c02095972ccb1753df1efa12d50234ae12fe774 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:11:57 +1000 Subject: [PATCH 10/24] [PIR #1233] Test: update #929/#1241/#1267 assertions to the session-aware launcher names --- .../__tests__/launch-loop-exit-code.test.ts | 4 +- .../__tests__/spawn-worktree.test.ts | 42 ++++++++++++------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/codev/src/agent-farm/__tests__/launch-loop-exit-code.test.ts b/packages/codev/src/agent-farm/__tests__/launch-loop-exit-code.test.ts index 01b099800..ad075e01d 100644 --- a/packages/codev/src/agent-farm/__tests__/launch-loop-exit-code.test.ts +++ b/packages/codev/src/agent-farm/__tests__/launch-loop-exit-code.test.ts @@ -88,7 +88,9 @@ describe('builder launch loop exit handling (Bugfix #1241)', () => { }); expect(runCount()).toBeGreaterThan(1); - expect(result.stdout).toContain('Restarting in 2 seconds'); + // PIR #1233: the default (claude) harness crash branch resumes the pinned + // conversation rather than replaying fresh — same auto-restart, new wording. + expect(result.stdout).toContain('Resuming the conversation in 2 seconds'); expect(result.stdout).toContain('code 7'); expect(result.stdout).not.toContain('Agent exited at your request'); }, 15_000); diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index c486563be..e552dea8a 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -355,7 +355,7 @@ describe('spawn-worktree', () => { } /** The body of a generated `codev_launch_() { … }` launcher. */ - function launcherBody(script: string, name: 'initial' | 'fresh'): string | undefined { + function launcherBody(script: string, name: 'entry' | 'pinned' | 'unpinned' | 'resume'): string | undefined { return script.match(new RegExp(`codev_launch_${name}\\(\\) \\{\\n(.*)\\n\\}`))?.[1].trim(); } @@ -374,7 +374,7 @@ describe('spawn-worktree', () => { // fresh command, so this asserts on the *entry* launcher specifically: // the resumed conversation carries its own system prompt, so role // injection and the initial prompt stay off this path. - expect(launcherBody(script!, 'initial')).toBe("claude --resume 'abc-1234-uuid'"); + expect(launcherBody(script!, 'entry')).toBe("claude --resume 'abc-1234-uuid'"); expect(script).not.toContain('--resume,'); expect(script).toContain('while true'); // Resume never rewrites the prompt file: `afx reset` reads the spawn-time @@ -398,13 +398,18 @@ describe('spawn-worktree', () => { 'PROMPT', 'ROLE', 'codev', resume, ); - const fresh = launcherBody(findScript()!, 'fresh'); - expect(fresh).toBeDefined(); - expect(fresh).not.toContain('--resume'); - expect(fresh).toContain('--append-system-prompt'); - expect(fresh).toContain('.builder-prompt.txt'); - // …and the loop actually switches to it on the clean-exit branch. - expect(findScript()).toContain('codev_launch=codev_launch_fresh'); + // PIR #1233: the fresh relaunch is now the PINNED launcher — a new + // conversation under a newly minted id. The #1267 invariant is intact: + // no --resume on this path, role and prompt re-injected. + const pinned = launcherBody(findScript()!, 'pinned'); + expect(pinned).toBeDefined(); + expect(pinned).not.toContain('--resume'); + expect(pinned).toContain('--append-system-prompt'); + expect(pinned).toContain('.builder-prompt.txt'); + expect(pinned).toContain('--session-id "$codev_session_id"'); + // …and the clean-exit branch re-mints and switches to it. + expect(findScript()).toContain('codev_relaunch_fresh'); + expect(findScript()).toContain('codev_launch=codev_launch_pinned'); }); it('resume → the role file the fresh relaunch injects is (re)written', async () => { @@ -422,17 +427,24 @@ describe('spawn-worktree', () => { expect(roleCall![1]).toBe(`ROLE ${DEFAULT_TOWER_PORT}`); }); - it('no resume → single-command loop, unchanged (no launcher indirection)', async () => { + // PIR #1233: a fresh Claude spawn now gets the session-aware loop — it + // ENTERS on a pinned fresh invocation (never a resume), and the crash + // branch resumes that pinned conversation instead of replaying the prompt. + it('no resume → enters on a pinned fresh invocation, not a resume', async () => { await startBuilderSession( { workspaceRoot: '/tmp/ws' } as any, 'pir-1d', '/tmp/worktree', 'claude', 'PROMPT', 'ROLE', 'codev', ); - expect(findScript()).not.toContain('codev_launch'); + const entry = launcherBody(findScript()!, 'entry'); + expect(entry).toBeDefined(); + expect(entry).toContain('--session-id "$codev_session_id"'); + expect(entry).toContain('.builder-prompt.txt'); + expect(entry).not.toContain('--resume'); }); - it('no resume + role → fresh role-injected script, no --resume', async () => { + it('no resume + role → entry is role-injected and never a resume; --resume exists only in the crash-resume launcher', async () => { await startBuilderSession( { workspaceRoot: '/tmp/ws' } as any, 'pir-2', '/tmp/worktree', 'claude', @@ -441,8 +453,10 @@ describe('spawn-worktree', () => { const script = findScript(); expect(script).toBeDefined(); - expect(script).not.toContain('--resume'); - expect(script).toContain('--append-system-prompt'); + const entry = launcherBody(script!, 'entry'); + expect(entry).toContain('--append-system-prompt'); + expect(entry).not.toContain('--resume'); + expect(launcherBody(script!, 'resume')).toContain('--resume "$codev_session_id"'); }); // Bugfix #1241: every generated variant must gate the relaunch on exit From b4cdd57fb7f2378406b1a8c64380d6aca1faf66b Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:11:57 +1000 Subject: [PATCH 11/24] [PIR #1233] Manifest: register prompt-bearing doc touches for T16 (Spec 1280) --- .../manifests/pir-1233-crash-resume.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 codev/projects/1280-prompt-surface-judgment-not-ru/manifests/pir-1233-crash-resume.md diff --git a/codev/projects/1280-prompt-surface-judgment-not-ru/manifests/pir-1233-crash-resume.md b/codev/projects/1280-prompt-surface-judgment-not-ru/manifests/pir-1233-crash-resume.md new file mode 100644 index 000000000..a2a50ffc7 --- /dev/null +++ b/codev/projects/1280-prompt-surface-judgment-not-ru/manifests/pir-1233-crash-resume.md @@ -0,0 +1,17 @@ +# PIR #1233 — builder crash-restart resumes the session (prompt-bearing doc touches) + +Not a Spec 1280 phase: this branch makes the builder launch loop resume the +pinned conversation after a crash instead of respawning fresh (issue #1233). It +touches four prompt-bearing files — the PIR builder prompt and protocol in both +trees — with mechanical accuracy updates: the "crash relaunches you with the +same prompt" wording was made false by the code change and now describes the +resume-with-nudge behavior plus its degrade fallback. No prompt-surface-reduction +principles were applied; the deltas add the minimum words needed to keep the +docs truthful. + +| File | Old | New | Principles | Rationale | +|---|---:|---:|---|---| +| `codev/protocols/pir/builder-prompt.md` | 898 | 941 | none | Resumption After Crash section: crash now resumes with context + nudge; fresh relaunch is the degrade fallback | +| `codev/protocols/pir/protocol.md` | 2066 | 2114 | none | Builder Session Lifetime: loop pins a session id, resumes on crash, degrades to prompt replay when unresumable | +| `codev-skeleton/protocols/pir/builder-prompt.md` | 898 | 941 | none | Mirror of the codev/ change (both trees byte-identical) | +| `codev-skeleton/protocols/pir/protocol.md` | 2066 | 2114 | none | Mirror of the codev/ change (both trees byte-identical) | From e9f61f8d2dd07d985d0f171b66200d8936dbcdcb Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:12:43 +1000 Subject: [PATCH 12/24] chore(porch): 1233 dev-approval gate-requested --- codev/projects/1233-builder-crash-restart-loses-al/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index 8b2b01a12..9bb0838db 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -11,10 +11,11 @@ gates: approved_at: '2026-08-05T08:58:53.377Z' dev-approval: status: pending + requested_at: '2026-08-05T09:12:43.126Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T08:59:02.251Z' +updated_at: '2026-08-05T09:12:43.127Z' From 8ffe96cb9263d2408c942bf04d58c2d57c9149a5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:40:39 +1000 Subject: [PATCH 13/24] chore(porch): 1233 dev-approval gate-approved --- .../projects/1233-builder-crash-restart-loses-al/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index 9bb0838db..4de221aa0 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -10,12 +10,13 @@ gates: requested_at: '2026-08-05T08:53:20.352Z' approved_at: '2026-08-05T08:58:53.377Z' dev-approval: - status: pending + status: approved requested_at: '2026-08-05T09:12:43.126Z' + approved_at: '2026-08-05T09:40:39.220Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T09:12:43.127Z' +updated_at: '2026-08-05T09:40:39.221Z' From f5eaad37d71d2eb24e83ee36536ed7c836cbd1c1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:40:48 +1000 Subject: [PATCH 14/24] chore(porch): 1233 review phase-transition --- .../projects/1233-builder-crash-restart-loses-al/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index 4de221aa0..d8742438c 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -1,7 +1,7 @@ id: '1233' title: builder-crash-restart-loses-al protocol: pir -phase: implement +phase: review plan_phases: [] current_plan_phase: null gates: @@ -19,4 +19,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T09:40:39.221Z' +updated_at: '2026-08-05T09:40:48.204Z' From 183e4acf979f8f8f0dd2d589b7a0cea7d4549018 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:42:40 +1000 Subject: [PATCH 15/24] [PIR #1233] Review + retrospective --- codev/resources/arch.md | 2 + codev/resources/lessons-learned.md | 1 + .../1233-builder-crash-restart-loses-al.md | 67 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 codev/reviews/1233-builder-crash-restart-loses-al.md diff --git a/codev/resources/arch.md b/codev/resources/arch.md index 8d4bfecfa..e9988dbca 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -319,6 +319,8 @@ A `codev doctor` audit (`lib/framework-ref-audit.ts`) flags shell-fetch of frame **Architect conversation resume is stored-id-only (#832 / #1145).** Every architect launch path (`launchInstance` main spawn, `add-architect` / sibling reconcile, both shellper restart-bake sites) resolves through `resolveArchitectLaunch`: a session id stored on the workspace-scoped architect row (minted at spawn, pinned via the harness's `session.newSessionArgs`) is resumed only after the harness's optional `session.verifyOwnership` confirms the session file still exists for this cwd (Claude: `~/.claude/projects//.jsonl`, accepted under either the logical or physical form of a symlinked path). Anything else — no row, no stored id, missing file — spawns fresh with role injection and a newly minted, persisted id. `launchInstance`'s mtime-based jsonl-discovery fallback was **removed** by #1145: on a fresh workspace (`codev adopt` / first touch) it resumed whatever Claude conversation the user last held in that directory — hijacking personal sessions, roleless too, since the resume path skips role injection. Do not reintroduce discovery on any architect path; mtime cannot distinguish an architect's session from a newer personal one in the same cwd. Discovery (`HarnessProvider.buildResume`, newest jsonl by mtime) survives for **builder resume only** (`spawn.ts` `discoverResumeSession`), harness-gated to claude — the gating that fixes the latent crash-loop where a non-Claude harness + a stale Claude `.jsonl` built an invalid ` --resume ` invocation and shellper restart-looped to death. +**Builder crash restarts resume the pinned conversation (#1233).** Every Claude-harness builder spawn mints a fresh session UUID (never reused across spawns — #1224) and pins it into the generated `.builder-start.sh` via the harness's `session.newSessionScriptFragment` (`--session-id`). On an unnatural exit (nonzero / signal, bash's 128+N — the jetsam-SIGKILL class from #1227) the wrapper's loop (`buildSessionLaunchLoop`, spawn-worktree.ts) runs `--resume "$codev_session_id"` with a short nudge prompt instead of replaying the spawn prompt into a fresh session; the nudge matters because `--resume` restores the transcript but not a turn — without it an unattended builder idles. Three consecutive fast failures (< `CODEV_LAUNCH_FAST_FAIL_SECS`, default 15s — the unresumable-jsonl / held-id class, #1145/#1149) degrade to a prompt-replay relaunch under a re-minted id; a clean exit keeps #1267's Enter-gated fresh relaunch, also under a new pinned id. The wrapper maintains `.builder-session-id` in the worktree as the current-id surface, and **bash is its sole writer** — after a runtime re-mint no Node/DB copy can be accurate (consumption by `--resume`/recover is #1112's scope; recover still uses `buildResume` mtime discovery today). Harnesses without the script-form session seam (codex/gemini/opencode/custom) generate the historical loop byte-for-byte. The state machine lives in generated bash deliberately: builder PTYs survive Tower restarts, so no Node process is guaranteed to exist at crash time. + **Architect role injection is centralized in `buildArchitectArgs`** (`tower-utils.ts`), the shared helper every architect-launch path routes through — `launchInstance` (fresh), `add-architect` (sibling), shellper reconnect (×2), and the no-Tower `afx architect` (refactored in #929 to call `buildArchitectArgs` instead of duplicating injection). So the architect role is injected on **every** launch path, not just first-activation. (No architect context-file seam exists: claude/codex read project context natively; the gemini-only `getArchitectFiles` seam #1059 introduced was removed when gemini's architect support was dropped.) #### Multi-Architect Support (Spec 755 / Spec 786) diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index b782adad5..426cbc0c9 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -444,6 +444,7 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated - [From 1011] Framework files (protocol docs, templates, resource docs) default to the package skeleton (resolver tier 4) and are not on disk in a fresh install. Builder-facing prompts and docs must *deliver* them through resolver-aware channels (the `{{protocol_reference}}` context var; the `{{> path}}` include resolved by `resolveCodevIncludes`), never fetch them by literal `codev/...` path (`cat`/`cp`). A path fetch bypasses the four-tier resolver and fails in fresh installs. Referencing a path in prose for orientation is fine; the rule is about fetching, not referencing. - [From 1011] Soft-mode protocols (experiment/spike) have no porch phase prompts, so `protocol.md` is their only guidance channel and templates must be injected into it; strict-mode protocols carry structure in phase prompts. A delivery fix has to cover both channels. - [From 1011] Don't drop a "use the template at ``" pointer as a dead reference. The spir/aspir plan template carries the machine-readable phases JSON porch's plan gate requires (`has_phases_json`), so it must be *delivered* via a porch-resolved `{{> }}` include, not removed. +- [From 1233] `--resume` restores the transcript, not the momentum: a resumed interactive agent sits idle until a user turn arrives. For unattended agents (builders), deliver a short nudge prompt with the resume (`claude --resume ""` — verified to process the positional prompt as the next turn); otherwise a fix for context loss silently becomes a stalled-lane incident, which is harder to spot because the pane looks healthy. Human-attended sessions (architects) don't need the nudge. ## Debugging and Root Cause Analysis diff --git a/codev/reviews/1233-builder-crash-restart-loses-al.md b/codev/reviews/1233-builder-crash-restart-loses-al.md new file mode 100644 index 000000000..d0b9487fb --- /dev/null +++ b/codev/reviews/1233-builder-crash-restart-loses-al.md @@ -0,0 +1,67 @@ +# PIR Review: Builder crash-restart resumes the session instead of respawning fresh + +Fixes #1233 + +## Summary + +Builder `.builder-start.sh` wrappers previously handled any claude crash (notably the #1227 jetsam-SIGKILL class) by respawning a brand-new session with the original spawn prompt — total conversation amnesia, two seconds after every kill. This PR expresses the architect resume pattern (#832/#1264) in the generated bash: a session UUID is minted and pinned at spawn (`--session-id`), unnatural exits resume that conversation (`--resume` plus a re-orientation nudge so the unattended builder acts instead of idling), three consecutive fast failures degrade to the historical prompt-replay under a re-minted id, and #1267's clean-exit-stays-fresh semantics are preserved but now crash-protected under a new id. Session-less harnesses (codex/gemini/opencode/custom) generate byte-identical scripts to before. + +## Files Changed + +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts` (+238 / -23) — `buildSessionLaunchLoop` state machine, `scriptSessionForms`, `CRASH_RESUME_NUDGE`; `startBuilderSession` / `buildWorktreeLaunchScript` wiring +- `packages/codev/src/agent-farm/utils/harness.ts` (+20 / -0) — optional `newSessionScriptFragment` / `resumeScriptFragment` on the `session` seam; Claude implementation +- `packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts` (+241 / -0) — new executed-bash suite +- `packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts` (+27 / -15) — #929/#1267 assertions updated to the session-aware launcher names (intents preserved) +- `packages/codev/src/agent-farm/__tests__/launch-loop-exit-code.test.ts` (+4 / -2) — crash-branch message wording +- `codev/protocols/pir/{builder-prompt,protocol}.md` + `codev-skeleton/protocols/pir/{builder-prompt,protocol}.md` (+2 / -2 each) — crash-relaunch wording now describes resume semantics; both trees byte-identical +- `codev/projects/1280-prompt-surface-judgment-not-ru/manifests/pir-1233-crash-resume.md` (+17) — T16 manifest rows for the four prompt-bearing doc touches +- `codev/resources/arch.md`, `codev/resources/lessons-learned.md` — routed updates (see below) +- `codev/plans/1233-builder-crash-restart-loses-al.md`, `codev/state/pir-1233_thread.md` — plan and thread artifacts + +## Commits + +- `8e4156c2` [PIR #1233] Crash restarts resume the pinned session instead of respawning fresh +- `11e788c8` [PIR #1233] docs: crash-restart now resumes; update PIR loop descriptions in both trees +- `d158f2fd` [PIR #1233] Test: execute the session-aware loop and assert resume/degrade/re-mint behavior +- `05184d2e` [PIR #1233] thread: implement-phase notes +- `6c020959` [PIR #1233] Test: update #929/#1241/#1267 assertions to the session-aware launcher names +- `b4cdd57f` [PIR #1233] Manifest: register prompt-bearing doc touches for T16 (Spec 1280) +- (review-phase commit) [PIR #1233] Review + retrospective + +## Test Results + +- `pnpm build`: ✓ pass +- `pnpm test`: ✓ pass (4,406 passed / 48 skipped; 13 new executed-bash tests) +- Empirical CLI verification: `claude -p --session-id ` then `claude -p --resume ""` — context restored (codeword round-trip) and the positional nudge prompt processed as the next turn +- Manual verification: human-reviewed at the `dev-approval` gate (code + generated-script walkthrough; live kill -9 test procedure provided) + +## Architecture Updates + +Routed **COLD** (`codev/resources/arch.md`, Agent Farm Internals): a new paragraph "Builder crash restarts resume the pinned conversation (#1233)" alongside the existing #832/#1145 architect-resume paragraph — covering the pin-at-spawn/resume-on-crash state machine, the nudge rationale, the fast-fail degrade, `.builder-session-id` with bash as sole writer (consumption deferred to #1112), session-less byte-identity, and why the machinery lives in generated bash (builder PTYs outlive Tower). Not HOT: this is subsystem mechanics to consult when touching spawn/recovery, not a fact that should steer every decision; the hot cap is better spent on its current entries. + +## Lessons Learned Updates + +Routed **COLD** (`codev/resources/lessons-learned.md`, Protocol Orchestration): `--resume` restores the transcript, not the momentum — unattended agents need a turn trigger (the nudge prompt) or a context-loss fix silently becomes a stalled-lane incident. Not HOT: narrow to agent-resume design, below the cross-cutting bar. + +## Things to Look At During PR Review + +- **The bash state machine** (`buildSessionLaunchLoop`, `spawn-worktree.ts`) is the subtle core: the degrade path must never `--resume` a stale id after falling back to an unpinned launch (guarded by the launcher-name check), and the clean-exit re-mint must happen *after* the `read` so a vanished terminal (EOF) never mutates state on the way out. The executed-bash tests pin both. +- **The fast-fail threshold heuristic** (3 consecutive nonzero exits under 15s) is a wrapper-side approximation of the shellper's SessionManager crash-loop detector. A jetsam storm that kills three resumes inside 15s would degrade to prompt-replay — i.e., today's behavior; acceptable, but it is a heuristic, not a proof. +- **Test-layer discipline** (#1244 finding): the wrapper sees bash's 128+N for signal deaths; node-pty reports `{exitCode: 0, signal}`. All new tests execute real bash and assert wrapper-layer codes only. +- **Deviation from the approved plan** (minor, deliberate): Node does not pre-write `.builder-session-id` at spawn; the bash script is the sole writer (runs `codev_persist_session_id` before the first launch). One writer beats two writers of the same value. +- Behavioral side effects flagged at the plan gate, on the record for reviewers: crash-restart no longer re-reads `.builder-prompt.txt`/`.builder-role.md` (edits land only on a clean-exit relaunch), and crashes no longer accidentally "solve" a near-full context window (use `afx reset` for that). + +## How to Test Locally + +- **View diff**: VSCode sidebar → right-click builder `pir-1233` → **View Diff** +- **Automated**: `cd .builders/pir-1233/packages/codev && pnpm exec vitest run src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts` +- **Live end-to-end** (requires installing the branch build: `pnpm build && pnpm -w run local-install` from the worktree): + 1. Spawn a scratch builder; verify `.builder-start.sh` contains `codev_launch_resume()` and `.builder-session-id` holds its UUID + 2. Tell it a codeword, `kill -9` its claude pid → "Resuming the conversation in 2 seconds…" → ask for the codeword back + 3. Double-Ctrl+C → Enter → fresh conversation, new UUID in `.builder-session-id` + 4. Move the session jsonl out of `~/.claude/projects//`, `kill -9` → three fast resume attempts, then fresh prompt-replay relaunch + +## Notes + +- New builders get the resumable wrapper only at spawn time; already-running builders keep the old loop until respawned. No migration needed. +- `.builder-session-id` format (one lowercase UUID + newline) becomes a soft contract for #1112, which owns making `--resume`/recover consume it in place of mtime discovery. From 10e5e5d88f776c305bf16319cdc22beda60c14b8 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:42:59 +1000 Subject: [PATCH 16/24] chore(porch): 1233 record PR #1356 --- .../1233-builder-crash-restart-loses-al/status.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index d8742438c..3992c0765 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -19,4 +19,9 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T09:40:48.204Z' +updated_at: '2026-08-05T09:42:59.088Z' +pr_history: + - phase: review + pr_number: 1356 + branch: builder/pir-1233 + created_at: '2026-08-05T09:42:59.087Z' From bc9318080f58bacee73a6670b4b81722f3789b69 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:43:07 +1000 Subject: [PATCH 17/24] chore(porch): 1233 review build-complete --- .../projects/1233-builder-crash-restart-loses-al/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index 3992c0765..6193924b3 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -16,10 +16,10 @@ gates: pr: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T09:42:59.088Z' +updated_at: '2026-08-05T09:43:07.839Z' pr_history: - phase: review pr_number: 1356 From 06e8a9e39b7e72367fc5850e66d1534940084acd Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:50:01 +1000 Subject: [PATCH 18/24] [PIR #1233] Test: direct worktree-mode assertion (consult follow-up); record consult outcome in review --- codev/reviews/1233-builder-crash-restart-loses-al.md | 1 + .../__tests__/pir-1233-session-launch-loop.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/codev/reviews/1233-builder-crash-restart-loses-al.md b/codev/reviews/1233-builder-crash-restart-loses-al.md index d0b9487fb..9d7d269fe 100644 --- a/codev/reviews/1233-builder-crash-restart-loses-al.md +++ b/codev/reviews/1233-builder-crash-restart-loses-al.md @@ -50,6 +50,7 @@ Routed **COLD** (`codev/resources/lessons-learned.md`, Protocol Orchestration): - **Test-layer discipline** (#1244 finding): the wrapper sees bash's 128+N for signal deaths; node-pty reports `{exitCode: 0, signal}`. All new tests execute real bash and assert wrapper-layer codes only. - **Deviation from the approved plan** (minor, deliberate): Node does not pre-write `.builder-session-id` at spawn; the bash script is the sole writer (runs `codev_persist_session_id` before the first launch). One writer beats two writers of the same value. - Behavioral side effects flagged at the plan gate, on the record for reviewers: crash-restart no longer re-reads `.builder-prompt.txt`/`.builder-role.md` (edits land only on a clean-exit relaunch), and crashes no longer accidentally "solve" a near-full context window (use `afx reset` for that). +- **Consultation outcome (single pass, both APPROVE/HIGH):** Codex — no issues. Claude — no blocking issues; its one substantive note (no direct test on the worktree-mode claude path) was fixed post-consult with a dedicated assertion. Remaining minor notes, acknowledged as-is: the builder-shaped nudge wording also reaches human-driven worktree-mode sessions (harmless); `.builder-session-id` joins the existing untracked `.builder-*` family (a `.builder-*` gitignore is a candidate follow-up for #1112); the degrade message wording is imprecise when the pinned-fresh launcher is itself the fast-failing one (behavior correct, wording only). ## How to Test Locally diff --git a/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts b/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts index 651e7da8e..a55869ad9 100644 --- a/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts +++ b/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts @@ -20,6 +20,7 @@ import { tmpdir } from 'node:os'; import { buildSessionLaunchLoop, buildLaunchLoop, + buildWorktreeLaunchScript, scriptSessionForms, CRASH_RESUME_NUDGE, SESSION_ID_EXPR, @@ -238,4 +239,13 @@ describe('PIR #1233 — downstream consumers of the generated script', () => { it('the nudge prompt contains no single quotes (it is embedded single-quoted in bash)', () => { expect(CRASH_RESUME_NUDGE).not.toContain("'"); }); + + // Consultation follow-up: the worktree-mode (no-prompt) path gets the + // session-aware loop too — asserted directly, not incidentally. + it('buildWorktreeLaunchScript (claude, no role) generates the session-aware loop', () => { + const script = buildWorktreeLaunchScript(dir, 'claude', null, dir); + expect(script).toContain(`--session-id ${SESSION_ID_EXPR}`); + expect(script).toContain('codev_launch_resume()'); + expect(script).toContain(CRASH_RESUME_NUDGE); + }); }); From a8941f4fc2e31ab21e3b34b572aad8d511a97d48 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:50:10 +1000 Subject: [PATCH 19/24] chore(porch): 1233 pr gate-requested --- .../1233-builder-crash-restart-loses-al/status.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index 6193924b3..871724dd7 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -15,13 +15,15 @@ gates: approved_at: '2026-08-05T09:40:39.220Z' pr: status: pending + requested_at: '2026-08-05T09:50:10.957Z' iteration: 1 -build_complete: true +build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T09:43:07.839Z' +updated_at: '2026-08-05T09:50:10.957Z' pr_history: - phase: review pr_number: 1356 branch: builder/pir-1233 created_at: '2026-08-05T09:42:59.087Z' +pr_ready_for_human: true From fcc397c9c98db2a39c228d75bfab297dad1b11dc Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:50:33 +1000 Subject: [PATCH 20/24] [PIR #1233] thread: PR #1356 and consult outcome --- codev/state/pir-1233_thread.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/codev/state/pir-1233_thread.md b/codev/state/pir-1233_thread.md index fb8a64eb9..c2480725b 100644 --- a/codev/state/pir-1233_thread.md +++ b/codev/state/pir-1233_thread.md @@ -23,3 +23,11 @@ Gate discussion (recorded for the review): blast radius (every Claude builder sp - Byte-identity subtlety: kept the historical double-space in role-bearing commands with empty fragments (gemini) so session-less scripts are truly byte-identical. - 13 new executed-bash tests green (crash→resume, clean-exit re-mint, sticky switch, degrade, threshold gating, recover variant, harness gating, reset detection). - Docs: PIR builder-prompt + protocol "crash relaunches you with the same prompt" wording updated to resume semantics, mirrored to codev-skeleton (verified byte-identical). +- Spec 1280's T16 manifest guard fired on the doc touches; registered them in `manifests/pir-1233-crash-resume.md` (pir-1189 precedent). + +## Review phase (2026-08-05) + +- dev-approval approved after gate Q&A (blast radius, file-vs-DB persistence, discovery-vs-pin, nudge rationale, death-capture mechanics, test procedure). Review file written; arch routed COLD (arch.md Agent Farm Internals paragraph), lesson routed COLD (lessons-learned.md Protocol Orchestration: resume restores transcript, not momentum). +- PR #1356 opened with review as body; recorded via porch done --pr. +- CMAP (2-way, single pass): claude=APPROVE/HIGH, codex=APPROVE/HIGH, none blocking. Claude's substantive minor (no direct worktree-mode loop assertion) fixed in 06e8a9e3; nudge-in-worktree-mode wording, `.builder-*` gitignore idea, and degrade-message wording acknowledged in review (gitignore noted as #1112-adjacent follow-up). +- Sitting at the pr gate. From 861de760c9dab6ee07663885f7f60d06087e1855 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 5 Aug 2026 19:51:54 +1000 Subject: [PATCH 21/24] chore(porch): 1233 pr gate-approved --- .../1233-builder-crash-restart-loses-al/status.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index 871724dd7..996de2833 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -14,16 +14,17 @@ gates: requested_at: '2026-08-05T09:12:43.126Z' approved_at: '2026-08-05T09:40:39.220Z' pr: - status: pending + status: approved requested_at: '2026-08-05T09:50:10.957Z' + approved_at: '2026-08-05T09:51:54.911Z' iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T09:50:10.957Z' +updated_at: '2026-08-05T09:51:54.912Z' pr_history: - phase: review pr_number: 1356 branch: builder/pir-1233 created_at: '2026-08-05T09:42:59.087Z' -pr_ready_for_human: true +pr_ready_for_human: false From 3add3eed6f122eba3754d88c7cd69fa27f430851 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 8 Aug 2026 21:34:15 +1000 Subject: [PATCH 22/24] chore(porch): 1233 protocol complete --- .../projects/1233-builder-crash-restart-loses-al/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml index 996de2833..731017a3f 100644 --- a/codev/projects/1233-builder-crash-restart-loses-al/status.yaml +++ b/codev/projects/1233-builder-crash-restart-loses-al/status.yaml @@ -1,7 +1,7 @@ id: '1233' title: builder-crash-restart-loses-al protocol: pir -phase: review +phase: verified plan_phases: [] current_plan_phase: null gates: @@ -21,7 +21,7 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-05T08:47:09.607Z' -updated_at: '2026-08-05T09:51:54.912Z' +updated_at: '2026-08-08T11:34:15.276Z' pr_history: - phase: review pr_number: 1356 From a327238a0ca541b2af14d36002e6c16c36c35976 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 8 Aug 2026 21:39:30 +1000 Subject: [PATCH 23/24] [PIR #1233] Test: iterate BUILTIN_HARNESSES in harness gating (gemini retired in #1338) --- .../__tests__/pir-1233-session-launch-loop.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts b/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts index a55869ad9..2761a066e 100644 --- a/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts +++ b/packages/codev/src/agent-farm/__tests__/pir-1233-session-launch-loop.test.ts @@ -25,7 +25,7 @@ import { CRASH_RESUME_NUDGE, SESSION_ID_EXPR, } from '../commands/spawn-worktree.js'; -import { CLAUDE_HARNESS, CODEX_HARNESS, GEMINI_HARNESS, OPENCODE_HARNESS } from '../utils/harness.js'; +import { CLAUDE_HARNESS, BUILTIN_HARNESSES } from '../utils/harness.js'; import { harnessFromLaunchScript } from '../commands/reset/context.js'; const SPAWN_ID = 'aaaaaaaa-1111-2222-3333-444444444444'; @@ -193,10 +193,16 @@ describe('PIR #1233 — crash restarts resume the conversation', () => { describe('PIR #1233 — harness gating', () => { it('only the Claude harness offers script-form session support', () => { + // Iterate the live roster rather than naming harnesses, so a retired or + // added built-in (e.g. gemini's retirement, #1338) can't strand this test. + for (const [name, harness] of Object.entries(BUILTIN_HARNESSES)) { + if (name === 'claude') { + expect(scriptSessionForms(harness)).toBeDefined(); + } else { + expect(scriptSessionForms(harness)).toBeUndefined(); + } + } expect(scriptSessionForms(CLAUDE_HARNESS)).toBeDefined(); - expect(scriptSessionForms(CODEX_HARNESS)).toBeUndefined(); - expect(scriptSessionForms(GEMINI_HARNESS)).toBeUndefined(); - expect(scriptSessionForms(OPENCODE_HARNESS)).toBeUndefined(); }); it('claude renders pin/resume fragments around the caller-supplied id expression', () => { From 1d02762b0c82a85405f45e739a8f7d6b3dfbb754 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 8 Aug 2026 21:39:41 +1000 Subject: [PATCH 24/24] [PIR #1233] thread: CI drift fix (gemini harness retirement) before merge --- codev/state/pir-1233_thread.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codev/state/pir-1233_thread.md b/codev/state/pir-1233_thread.md index c2480725b..12e829487 100644 --- a/codev/state/pir-1233_thread.md +++ b/codev/state/pir-1233_thread.md @@ -31,3 +31,9 @@ Gate discussion (recorded for the review): blast radius (every Claude builder sp - PR #1356 opened with review as body; recorded via porch done --pr. - CMAP (2-way, single pass): claude=APPROVE/HIGH, codex=APPROVE/HIGH, none blocking. Claude's substantive minor (no direct worktree-mode loop assertion) fixed in 06e8a9e3; nudge-in-worktree-mode wording, `.builder-*` gitignore idea, and degrade-message wording acknowledged in review (gitignore noted as #1112-adjacent follow-up). - Sitting at the pr gate. + +## 2026-08-08 — pr gate approved; CI drift fix before merge + +- `pr` gate approved by human; porch task is now "merge PR #1356". +- First merge attempt blocked: required CI checks still running; then Unit Tests FAILED on the PR merge-ref — main had retired the gemini harness (#1338), deleting `GEMINI_HARNESS`, which my harness-gating test imported by name. +- Fix: merged origin/main into the branch (clean merge), rewrote the gating test to iterate `BUILTIN_HARNESSES` so roster changes can't strand it. Local: package build green, 77/77 tests green in the two affected files. Pushed a327238a; waiting on CI, then merging.