diff --git a/.claude/skills/afx/SKILL.md b/.claude/skills/afx/SKILL.md index 2b365b5ea..c6c6386d0 100644 --- a/.claude/skills/afx/SKILL.md +++ b/.claude/skills/afx/SKILL.md @@ -91,6 +91,57 @@ afx send taqwabench:architect "use porch.checks, don't fork protocol.json" # cr curl -s http://localhost:4100/api/workspaces | python3 -m json.tool # → each .name is a valid address ``` +## afx interrupt + +Sends an ESC keystroke to a builder's PTY — the only thing that reaches it **mid-turn**. + +``` +afx interrupt +``` + +| Flag | Description | +|------|-------------| +| `--no-enter` | Send ESC alone, without the trailing Enter | + +A builder chaining foreground waits inside one turn queues every `afx send` unread — including your order +to stop. ESC ends the turn so the queue processes. Distinct from `afx send --interrupt` (Ctrl+C). + +```bash +afx interrupt 0042 +afx send 0042 "That producer died — stop waiting and report." +``` + +## afx reset + +Resets a builder's context: save working state → `/clear` → re-orient. Use when a builder's context window +is exhausted; `afx spawn --resume` reattaches the *same* conversation and does **not** give it a fresh one. + +``` +afx reset +``` + +| Flag | Description | +|------|-------------| +| `--dry-run` | Print what would be sent; write nothing to the builder | +| `--note ` | Extra context appended to the re-orientation | +| `--file ` | Append file content (48KB max, read from *your* filesystem) | +| `--interrupt-first` | ESC before the save request, for a builder already wedged | +| `--mode ` | Override mode if it cannot be detected | +| `--timeout ` | Wait for the save-state receipt (default 300) | +| `--min-bytes ` | Minimum state-file size to accept (default 1000) | +| `--quiet-window ` | Terminal silence counting as turn-ended (default 1500) | + +Every gate fails safe: if the state file never arrives, carries a stale nonce, is a stub, is still being +written, or the builder will not go quiet, the command **aborts without clearing** and exits non-zero, +naming the gate. Requires a harness with in-session reset (Claude Code); others abort loudly. + +```bash +afx reset 0042 --dry-run # inspect first — touches nothing +afx reset 0042 +afx reset 0042 --note "PR #90 merged while you were mid-phase. Rebase first." +afx reset 0042 --interrupt-first # builder is wedged mid-turn +``` + ## afx cleanup Removes a builder's worktree and branch after work is done. diff --git a/.codex/skills/afx/SKILL.md b/.codex/skills/afx/SKILL.md index 2b365b5ea..c6c6386d0 100644 --- a/.codex/skills/afx/SKILL.md +++ b/.codex/skills/afx/SKILL.md @@ -91,6 +91,57 @@ afx send taqwabench:architect "use porch.checks, don't fork protocol.json" # cr curl -s http://localhost:4100/api/workspaces | python3 -m json.tool # → each .name is a valid address ``` +## afx interrupt + +Sends an ESC keystroke to a builder's PTY — the only thing that reaches it **mid-turn**. + +``` +afx interrupt +``` + +| Flag | Description | +|------|-------------| +| `--no-enter` | Send ESC alone, without the trailing Enter | + +A builder chaining foreground waits inside one turn queues every `afx send` unread — including your order +to stop. ESC ends the turn so the queue processes. Distinct from `afx send --interrupt` (Ctrl+C). + +```bash +afx interrupt 0042 +afx send 0042 "That producer died — stop waiting and report." +``` + +## afx reset + +Resets a builder's context: save working state → `/clear` → re-orient. Use when a builder's context window +is exhausted; `afx spawn --resume` reattaches the *same* conversation and does **not** give it a fresh one. + +``` +afx reset +``` + +| Flag | Description | +|------|-------------| +| `--dry-run` | Print what would be sent; write nothing to the builder | +| `--note ` | Extra context appended to the re-orientation | +| `--file ` | Append file content (48KB max, read from *your* filesystem) | +| `--interrupt-first` | ESC before the save request, for a builder already wedged | +| `--mode ` | Override mode if it cannot be detected | +| `--timeout ` | Wait for the save-state receipt (default 300) | +| `--min-bytes ` | Minimum state-file size to accept (default 1000) | +| `--quiet-window ` | Terminal silence counting as turn-ended (default 1500) | + +Every gate fails safe: if the state file never arrives, carries a stale nonce, is a stub, is still being +written, or the builder will not go quiet, the command **aborts without clearing** and exits non-zero, +naming the gate. Requires a harness with in-session reset (Claude Code); others abort loudly. + +```bash +afx reset 0042 --dry-run # inspect first — touches nothing +afx reset 0042 +afx reset 0042 --note "PR #90 merged while you were mid-phase. Rebase first." +afx reset 0042 --interrupt-first # builder is wedged mid-turn +``` + ## afx cleanup Removes a builder's worktree and branch after work is done. diff --git a/codev-skeleton/resources/commands/agent-farm.md b/codev-skeleton/resources/commands/agent-farm.md index dfa36678f..c38db1db0 100644 --- a/codev-skeleton/resources/commands/agent-farm.md +++ b/codev-skeleton/resources/commands/agent-farm.md @@ -368,6 +368,113 @@ afx send 42 --file src/api.ts "Review this implementation" --- +### afx interrupt + +Interrupt a builder mid-turn by sending an ESC keystroke to its PTY. + +```bash +afx interrupt [options] +``` + +**Arguments:** +- `builder` - Target builder. Same addressing as `afx send`. + +**Options:** +- `--no-enter` - Send ESC alone, without the trailing Enter + +**Description:** + +This is the only recovery that reaches a builder **mid-turn**. When a builder chains foreground waits +inside a single turn, every `afx send` — including your order to stop — queues unread until that turn +ends. ESC interrupts the running tool and ends the turn, after which the queued messages process. The +trailing Enter (default) is what lets them through. + +Distinct from `afx send --interrupt`, which sends Ctrl+C. + +**Examples:** + +```bash +# Builder is wedged on a foreground wait and not reading messages +afx interrupt 0042 + +# Then the queued instruction lands +afx send 0042 "That producer died — stop waiting and report." +``` + +--- + +### afx reset + +Reset a builder's context: have it save its working state, clear the conversation, then re-orient it. + +```bash +afx reset [options] +``` + +**Arguments:** +- `builder` - Target builder. Same addressing as `afx send`. + +**Options:** +- `--note ` - Extra context appended to the re-orientation +- `--file ` - Append file content to the re-orientation (48KB max, read from *your* filesystem) +- `--dry-run` - Print the save request and both re-orientation payloads; write nothing to the builder +- `--interrupt-first` - Send ESC before the save request, for a builder already wedged mid-turn +- `--mode ` - Override the builder mode if it cannot be detected +- `--timeout ` - How long to wait for the save-state receipt (default 300) +- `--min-bytes ` - Minimum state-file size to accept as substantive (default 1000) +- `--quiet-window ` - Terminal silence that counts as turn-ended (default 1500) + +**Description:** + +Long-running builders exhaust their context window. `afx spawn --resume` reattaches the *same* +conversation, so a deep session resumes deep — it does not give the builder a fresh window. `afx reset` +does, without losing what the builder knows. + +The sequence: + +1. Assemble the re-orientation and write it to `.builder-reorient.md` in the worktree. +2. Ask the builder to write its complete working state to `.builder-state.md`, stamped with a one-time + nonce. +3. Wait for that file and **verify** it: correct nonce (not a stale file from an earlier reset), + substantive size, and stable across two observations (not still being written). +4. Wait for the terminal to fall silent, so the clear is not typed mid-turn. If it does not settle, send + **one** ESC and wait again. +5. Send `/clear`. +6. Deliver the re-orientation: role, protocol, mode, project, worktree, branch, the porch re-entry + instruction, and a pointer to the state file. + +**Every gate fails safe.** If the state file never arrives, carries the wrong nonce, is a stub, is still +growing, or the builder will not go quiet — the command **aborts without clearing** and exits non-zero, +naming the gate that failed. A builder whose context was not cleared has lost nothing. + +Both worktree artifacts use the `.builder-` prefix, so `afx cleanup` still classifies the worktree as +clean, and both are untracked so `porch done`'s staged-file sweep cannot pick them up. + +Requires a harness with in-session context reset (Claude Code). Other harnesses abort loudly rather than +substituting a different mechanism — use the boundary-recycle pattern instead (let the builder finish, +then `afx spawn --resume`). + +**Examples:** + +```bash +# See exactly what would be sent, without touching the builder +afx reset 0042 --dry-run + +# Standard reset +afx reset 0042 + +# Add context that post-dates the builder's saved state +afx reset 0042 --note "PR #90 merged while you were mid-phase. Rebase before continuing." + +# The builder is wedged mid-turn and not reading messages +afx reset 0042 --interrupt-first + +# A builder that legitimately needs longer to write its state +afx reset 0042 --timeout 600 +``` + +--- + ### afx open Open file annotation viewer. diff --git a/codev-skeleton/roles/builder.md b/codev-skeleton/roles/builder.md index be5d7cbf5..15bb1f8d0 100644 --- a/codev-skeleton/roles/builder.md +++ b/codev-skeleton/roles/builder.md @@ -193,6 +193,33 @@ Can't find the auth helper mentioned in spec. Options: Waiting for Architect guidance. ``` +## Waiting on external work + +The section above covers being blocked on *the architect*. This one covers being blocked on *an +artifact* — a file another agent is producing, a build, a queue, a sibling builder's output. That case +has its own failure mode, and it is the one that strands builders. + +**A wait is a claim that a producer exists.** Before waiting on an artifact, confirm the process meant to +produce it is actually alive. In the incident that motivated this guidance (2026-07-27), a builder waited +45+ minutes on a file whose producing process had already died. The wait could never have succeeded; it +was not slow, it was unsatisfiable. Checking first costs seconds. + +**Run waits as tracked background tasks that end your turn.** Start the wait in the background and finish +your turn. You are re-invoked when it completes, so the lane keeps moving *and* you stay addressable in +the meantime. A turn that ends is a turn someone can interrupt. + +**Never chain foreground poll loops.** This is the rule that matters most, and the reason is not +efficiency. Every `afx send` to you — including the architect's order to stop, including a reset +request — **queues unread until your current turn ends**. A turn that never ends is a builder that cannot +be reached by anyone, doing work nobody can redirect. You will not notice, because from inside the turn +everything looks fine. + +**If you are wedged anyway, you are not unreachable.** The architect can send you an ESC keystroke with +`afx interrupt `, which ends the running turn so your queued messages process. They can also run +`afx reset ` to have you save your working state, clear your context, and be re-oriented — the +supported recovery when your context window is exhausted rather than merely stuck. Neither requires you +to do anything; both are worth knowing exist, so you can suggest them when you notice you are in trouble. + ## Multi-PR Workflow Builders may submit multiple sequential PRs within a single worktree session. The worktree persists across PRs -- it is not cleaned up automatically after merge. This allows builders to do follow-up work (e.g., addressing review feedback in a second PR, or splitting large features across checkpoint PRs). diff --git a/codev/plans/1273-builder-context-reset-should-b.md b/codev/plans/1273-builder-context-reset-should-b.md new file mode 100644 index 000000000..aa41c58ce --- /dev/null +++ b/codev/plans/1273-builder-context-reset-should-b.md @@ -0,0 +1,708 @@ +# Plan: Builder context reset as a first-class flow (`afx reset`, `afx interrupt`, wait discipline) + +## Metadata +- **ID**: plan-2026-07-28-builder-context-reset +- **Status**: draft +- **Specification**: [codev/specs/1273-builder-context-reset-should-b.md](../specs/1273-builder-context-reset-should-b.md) +- **Created**: 2026-07-28 +- **Issue**: #1273 + +## Executive Summary + +The spec selects **Approach A** — in-session `/clear` plus an assembled re-orientation — and its safety +rests entirely on four ordering/evidence invariants (R1 assemble-before-destroy, R2 fresh-receipt gate, +R3 complete frame, R4 quiescence-or-abort). This plan is organised so those invariants are **enforced by +module boundaries, not by discipline**: the reset state machine is a pure, dependency-injected module +whose I/O (clock, filesystem, terminal write, terminal read) arrives as ports, so every invariant can be +tested by inverting the ordering and observing a failure — with no Tower, no PTY and no live builder. + +Phase ordering follows dependency and risk. `afx interrupt` ships **first** because it is small, +independently valuable (it is today's recovery of record, currently reachable only as folklore), and +because reset's R4 escalation reuses its ESC delivery path. Observability (`lastDataAt`) ships second +because R4 cannot be implemented — or honestly tested — without a quiescence signal. The three reset +modules follow, each independently testable, and the orchestrator that composes them lands only once its +parts are proven. Documentation lands last so it can describe what was actually built. + +**Scope discipline**: per architect directive, `afx interrupt` is deliberately one small phase with no +design latitude; the design effort is concentrated in phases 3–5. + +## Success Metrics + +Copied from the spec, plus implementation-specific metrics: + +- [ ] All specification success criteria met (R1–R4, `afx interrupt` contract, wait-discipline docs) +- [ ] Each of R1, R2, R3, R4 has at least one test that **fails if the ordering is inverted or the gate + bypassed** — not merely a test that the happy path works +- [ ] `pnpm build` clean; `pnpm test` green in `packages/codev/` +- [ ] Zero changes to existing `afx send` behaviour (existing send tests pass untouched) +- [ ] New commands appear in `afx --help` and in the agent-farm command reference +- [ ] Wait-discipline guidance present in the skeleton builder role (primary) and mirrored minimally + into `codev/` + +## Resolved Parameters + +The spec left two Important open questions as *numbers only* (semantics were fixed there). Resolved here: + +| Parameter | Value | Flag | Rationale | +|---|---|---|---| +| State-file minimum size | 1000 bytes | `--min-bytes` | The one verified example was 203 lines (~8–10KB). A cold-reader-complete save is comfortably over 1KB; a three-line stub is 100–200 bytes. 1000 rejects stubs without false-rejecting a terse but genuine save. | +| Size-stability window | 2 consecutive equal-size observations ≥2000ms apart | — | Long enough that a builder mid-write does not read as stable; short enough not to dominate the run. | +| State-file poll interval | 2000ms | — | Cheap `stat` on one file. | +| State-file wait timeout | 300s | `--timeout` | A busy builder may take minutes to reach the request; the manual run took a similar order. | +| Quiet window (no PTY output) | 1500ms | `--quiet-window` | Claude Code's spinner emits continuously while a turn runs, so 1500ms of true silence reliably indicates the turn ended. | +| Quiescence wait (pre-escalation) | 60s | — | Bounded per R4. | +| Quiescence wait (post-escalation) | 30s | — | Bounded per R4; shorter because ESC should act immediately. | +| `/clear` confirmation | Best-effort, **report-only** | — | Terminal-output signatures are version-sensitive. Confirmation status is *reported*; it never gates and never changes ordering (spec's Open Question resolved this way explicitly). | + +## Phases (Machine Readable) + +```json +{ + "phases": [ + {"id": "phase_1", "title": "afx interrupt + ESC delivery path"}, + {"id": "phase_2", "title": "Quiescence observability (lastDataAt)"}, + {"id": "phase_3", "title": "Reset receipt gate (nonce, substance, stability)"}, + {"id": "phase_4", "title": "Builder context resolution (protocol, mode, harness capability)"}, + {"id": "phase_5", "title": "Re-orientation assembly (complete-or-abort)"}, + {"id": "phase_6", "title": "Reset orchestrator + CLI wiring"}, + {"id": "phase_7", "title": "Wait discipline and command documentation"} + ] +} +``` + +## Where reset's context actually comes from + +CMAP iteration 1 exposed a load-bearing assumption in the first draft: it spoke of a "resolved builder +record" as though the registry carried everything R3 needs. It does not, and the gap is worse than the +review stated. Verified against the code: + +| Fact R3 needs | Actually available where | Not available where | +|---|---|---| +| Protocol | porch `status.yaml` in the worktree (`protocol: aspir`); `parseAgentName(builderId)` as a secondary | **`builders.protocol_name` is NULL for spec-type builders** — `spawn.ts:488-492` never passes `protocolName`; only the `protocol`-type spawn path (`:620-625`) does. Every SPIR/ASPIR lane — the exact target of this feature — has a NULL there. | +| Phase | porch `status.yaml` (`phase`, `current_plan_phase`) | Registry (`builders.phase` is a coarse spawn-time value) | +| Mode (strict/soft) | The literal `## Mode: STRICT` line in the worktree's `.builder-prompt.txt` — what the builder was actually told | Nowhere in the DB. `resolveMode` computes it at spawn from flags + protocol defaults and discards it; a spawn-time `--soft` is not recoverable from protocol defaults afterwards. | +| Harness | The launch line in the worktree's `.builder-start.sh` — per-builder ground truth for the process actually running | Workspace config alone is unreliable: a config change after spawn would misreport a running builder's harness. | + +**Decision: do not add a `mode` (or `protocol`) column to the `builders` table.** Persisting would only +help builders spawned *after* this change — every currently-running lane would still read NULL, so the +worktree derivation is required regardless. Adding the column on top of that creates a second source of +truth for a fact the worktree already holds authoritatively, which is the duplication the "single source +of truth" lesson warns against. Phase 4 makes the worktree the single source and terminates every +resolution chain in a loud abort rather than a guess. + +## Phase Breakdown + +### Phase 1: `afx interrupt` + ESC delivery path +**Dependencies**: None + +#### Objectives +- Make the verified mid-turn recovery (`ESC` into the PTY) a reachable first-class command. +- Provide the guaranteed-immediate ESC delivery path that phase 5's R4 escalation will reuse. + +#### Deliverables +- [ ] `packages/core/src/tower-client.ts` — add `escape?: boolean` to `TowerClient.sendMessage`'s options + and forward it in the request body. **This is the client surface `afx interrupt` rides on**; the + first draft claimed the command "reuses the plumbing end-to-end" without listing it (CMAP catch). + `packages/codev/src/agent-farm/lib/tower-client.ts` is a pure re-export and needs no change. +- [ ] `packages/codev/src/agent-farm/commands/interrupt.ts` (new) — resolve target, POST to Tower. +- [ ] `packages/codev/src/agent-farm/cli.ts` — register `interrupt [builder]` with `--no-enter`. +- [ ] `packages/codev/src/agent-farm/types.ts` — `InterruptOptions`. +- [ ] `packages/codev/src/agent-farm/servers/tower-routes.ts` — honour `options.escape` in `handleSend`. +- [ ] Tests: `packages/codev/src/agent-farm/__tests__/spec-1273-interrupt.test.ts`. + +#### Implementation Details + +`afx interrupt ` reuses `afx send`'s plumbing end-to-end — the same `TowerClient.sendMessage`, +the same `resolveTarget` addressing, the same workspace detection and sender identity. The only new +server behaviour is an `escape` option on `POST /api/send`: + +``` +options.escape === true → session.write('\x1b') + unless noEnter: session.write('\r') after SIMPLE_ENTER_DELAY_MS + never formatted, never buffered, never deferred +``` + +Two details are deliberate rather than incidental: + +1. **The trailing Enter is kept by default.** The verified recovery + (`afx send --raw "$(printf '\x1b')"`) sends it, and it is load-bearing: after ESC ends the + turn, the Enter is what lets already-queued messages process — the exact behaviour the architect + observed ("the queued messages then process"). `--no-enter` suppresses it. +2. **Buffer bypass is explicit, not inherited.** Today `shouldDefer = !interrupt && !session.isUserIdle(…)`. + An interrupt whose delivery could be deferred because someone recently typed in that terminal is not + an interrupt. `escape` joins `interrupt` in bypassing the buffer unconditionally. + +The CLI still sends `\x1b` as the message body (so the route's existing non-empty validation is +satisfied and the manual recipe and the new command exercise the same byte), which keeps the +trim-survival regression test meaningful for both paths. + +**Out of scope, explicitly** (architect directive: interrupt is small): `--all`, interrupt-then-message +composition (already available via `afx send --interrupt`), and any escalation ladder. + +#### Acceptance Criteria +- [ ] `afx interrupt 1273` writes `\x1b` then `\r` to the resolved builder's terminal. +- [ ] `--no-enter` writes `\x1b` alone. +- [ ] Delivery is never deferred by the send buffer, including when the session reports recent user input. +- [ ] Addressing parity with `afx send`: short id, full id, leading-zero forms; `AMBIGUOUS` on collision; + `NOT_FOUND` otherwise. +- [ ] A non-writable terminal fails loudly (matches #1198 behaviour), no false success. +- [ ] Regression test pins that a lone `\x1b` message survives `handleSend`'s `trim()` and non-empty check. +- [ ] `afx send` behaviour unchanged. + +#### Test Plan +- **Unit**: byte sequence with/without `--no-enter`; buffer-bypass with a session mocked as + *not* user-idle; ESC-survives-trim regression; resolution errors surfaced verbatim. +- **Integration**: none required — the send route already has integration coverage this rides on. +- **Manual**: interrupt a real builder mid-turn and confirm the turn ends and queued messages process. + +#### Rollback Strategy +Self-contained: revert the command file, the CLI registration and the `escape` branch. Nothing else +depends on it until phase 5. + +#### Risks +- **Risk**: `escape` handling accidentally alters the normal send path. + - **Mitigation**: the branch is a guarded early return placed before formatting and buffering; existing + send tests are run untouched as the regression signal. + +--- + +### Phase 2: Quiescence observability (`lastDataAt`) +**Dependencies**: None + +#### Objectives +- Expose the terminal-output timestamp that R4 needs, so quiescence is *measured* rather than assumed. + +#### Deliverables +- [ ] `packages/codev/src/terminal/pty-session.ts` — add `lastDataAt` to `PtySessionInfo` (declared in + the same file, `:30-40`) and to the `info` getter (`:504-516`). +- [ ] `packages/core/src/tower-client.ts` — add `lastDataAt` to `TowerTerminal`. +- [ ] Tests: **extend the existing** `packages/codev/src/agent-farm/__tests__/pty-last-data-at.test.ts` + (Spec 467) rather than creating a parallel file — it already covers `lastDataAt` tracking, and a + second file testing the same field invites drift. + +#### Implementation Details + +`PtySession` already tracks `_lastDataAt` (Spec 467) and exposes it as a getter; it is simply absent from +`info`, which is what `GET /api/terminals/:id` serialises. Surfacing it is a two-line change and gives a +precise, cheap quiescence signal. + +The rejected alternative — polling `GET /api/terminals/:id/output` and diffing successive tails — was +considered and is worse: it transfers output bytes on every poll, and it cannot distinguish "no new +output" from "new output identical to the old tail" (a spinner frame repeating). A monotonic timestamp +has neither problem. + +Additive only: an added field breaks no existing consumer. + +#### Acceptance Criteria +- [ ] `GET /api/terminals/:id` includes `lastDataAt` as an epoch-ms number. +- [ ] The value advances when the PTY produces output and is stable when it does not. +- [ ] Existing terminal tests pass unchanged. + +#### Test Plan +- **Unit**: `info.lastDataAt` reflects `_lastDataAt`; advances on `onPtyData`. +- **Integration**: terminal info endpoint returns the field. + +#### Rollback Strategy +Remove the field. Nothing depends on it until phase 5. + +#### Risks +- **Risk**: a serialised-shape snapshot test asserts exact object equality and breaks. + - **Mitigation**: run the terminal suite in this phase; fix any snapshot in the same commit. + +--- + +### Phase 3: Reset receipt gate (nonce, substance, stability) +**Dependencies**: None + +#### Objectives +- Implement R2 as a standalone, pure module: a state file is accepted only if it proves it is *this run's* + save, is substantive, and has stopped growing. + +#### Deliverables +- [ ] `packages/codev/src/agent-farm/commands/reset/receipt.ts` (new) — nonce generation, save-request + template, receipt verification. +- [ ] `packages/codev/src/agent-farm/commands/reset/constants.ts` (new) — the resolved parameters table. +- [ ] Tests: `packages/codev/src/agent-farm/__tests__/spec-1273-reset-receipt.test.ts`. + +#### Implementation Details + +**Nonce**: a short random token generated per run. The save request instructs the builder to begin the +file with an exact marker line containing it (e.g. an HTML comment, invisible in rendered markdown). +Freshness is proven by file *content*, not filename or mtime — so the state file keeps the **fixed** name +`.builder-state.md` and no per-reset litter accumulates in the worktree. mtime comparison was rejected as +the freshness signal: filesystem timestamp granularity and clock skew make it fragile, and it cannot +distinguish "rewritten in response to this request" from "touched". + +**Save-request template** — a single function returning the message body, containing: the nonce marker +line to reproduce verbatim, the exact target path, and the cold-reader content checklist (role and +mission; position in the protocol; receipts — what is done and verified, with file references; +in-flight work; open questions; standing orders from the architect; the next concrete action). + +**Verification** is a pure predicate over injected `stat`/`read` ports, returning a discriminated result +(`accepted` | `missing` | `wrong-nonce` | `too-small` | `still-growing`) so the orchestrator can report +precisely which gate failed rather than "timed out". + +The gate is deliberately structural. Per the spec's Security Considerations, prose-quality scoring is out +of scope — it would false-reject a file that cost the builder real work. + +#### Acceptance Criteria +- [ ] Accepts a nonce-bearing, ≥`minBytes`, size-stable file. +- [ ] Rejects: missing; present with a *previous* run's nonce; nonce-bearing but under `minBytes`; + nonce-bearing and still growing between observations. +- [ ] Nonces differ between runs. +- [ ] Verification performs no writes and no terminal I/O. + +#### Test Plan +- **Unit**: each rejection reason with injected fs ports; stability requires two equal observations + separated by the configured interval; save-request text contains the nonce and every checklist item. +- **Manual**: none. + +#### Rollback Strategy +Delete the module; nothing imports it until phase 5. + +#### Risks +- **Risk**: a builder reproduces the marker with altered spacing and fails the gate. + - **Mitigation**: match on the nonce token itself rather than the full line, and state the requirement + prominently in the save request. + +--- + +### Phase 4: Builder context resolution (protocol, mode, harness capability) +**Dependencies**: None + +#### Objectives +- Produce the facts R3 needs from sources that actually hold them, with every chain terminating in a loud + abort rather than a guess. +- Give the harness abstraction an explicit capability flag so "does this harness support in-session + context reset?" is a typed question, not an inference. + +#### Deliverables +- [ ] `packages/codev/src/agent-farm/commands/reset/context.ts` (new) — resolve + `{ protocol, phase, mode, harness, specName, planPath, issue }` for a builder. +- [ ] `packages/codev/src/agent-farm/utils/harness.ts` — add a `supportsContextReset` capability to + `HarnessProvider` (`:22`); true for `CLAUDE_HARNESS`, false/absent for the others. +- [ ] Tests: `packages/codev/src/agent-farm/__tests__/spec-1273-reset-context.test.ts`. + +#### Implementation Details + +Each field has an explicit precedence chain. None ends in a default: + +- **Protocol**: porch `status.yaml` under the worktree's `codev/projects/-/` → `parseAgentName(builderId)` + → **abort**. The registry's `builders.protocol_name` is deliberately *not* consulted: it is NULL for + every spec-type builder (`spawn.ts:488-492`), so reading it would look correct in review and fail on + exactly the lanes this feature targets. A comment records that, so a future reader does not "fix" it. +- **Phase**: `status.yaml` (`phase`, `current_plan_phase`). Absent means a non-porch lane — the porch + re-entry block is omitted and the omission is recorded in the report. This is a genuine branch, not a + gate failure, so it does **not** abort. +- **Mode**: `--mode` override → the literal `## Mode: STRICT|SOFT` line in the worktree's + `.builder-prompt.txt` (the text the builder was actually given, and correct even after `--resume`, + which does not rewrite it) → **abort** with an instruction to pass `--mode`. `resolveMode` cannot be + replayed after the fact: a spawn-time `--soft` is unrecoverable from protocol defaults. +- **Harness**: parse the launch line in the worktree's `.builder-start.sh` → map to a `HarnessProvider` → + require `supportsContextReset` → **abort** naming the harness otherwise. Per-builder ground truth beats + workspace config, which would misreport a running builder after a config change. + +`supportsContextReset` is added as an optional capability so the three non-Claude providers need no edit +and absence reads as unsupported — the safe direction. + +#### Acceptance Criteria +- [ ] Protocol resolves from `status.yaml` for a porch lane, and from the builder id when `status.yaml` + is absent; aborts when neither yields one. +- [ ] Resolution never consults `builders.protocol_name` (a test asserts a spec-type builder with NULL + `protocol_name` still resolves correctly). +- [ ] Mode resolves from `--mode`, else from `.builder-prompt.txt`; aborts naming `--mode` when neither. +- [ ] Harness resolves from `.builder-start.sh`; a provider without `supportsContextReset` aborts with + the harness named. +- [ ] A non-porch lane resolves without a phase and records the omission instead of aborting. +- [ ] Resolution is read-only: no writes, no terminal I/O. + +#### Test Plan +- **Unit**: each precedence chain, each abort, over injected fs ports; the NULL-`protocol_name` case + explicitly; `supportsContextReset` present/absent. +- **Manual**: run resolution against this workspace's own live builders and confirm the resolved + protocol/mode match what each was actually spawned with. + +#### Rollback Strategy +Delete `context.ts`; revert the one-line capability addition to `HarnessProvider`. Nothing imports it +until phase 6. + +#### Risks +- **Risk**: `.builder-prompt.txt` format drifts and the mode line stops parsing. + - **Mitigation**: abort (never guess), with `--mode` as the documented escape hatch; a test pins the + current rendered format. +- **Risk**: `HarnessProvider` is a framework interface with several implementations. + - **Mitigation**: the capability is optional, so absence means unsupported and no other provider needs + to change. + +--- + +### Phase 5: Re-orientation assembly (complete-or-abort) +**Dependencies**: Phase 4 + +#### Objectives +- Implement R3: assembly either produces a frame containing every required element, or it throws. There + is no code path that returns a partial frame. + +#### Deliverables +- [ ] `packages/codev/src/agent-farm/commands/reset/reorient.ts` (new) — resolved context → payload. +- [ ] Tests: `packages/codev/src/agent-farm/__tests__/spec-1273-reset-reorient.test.ts`. + +#### Implementation Details + +A single function takes the phase-4 resolved context plus the optional addendum and returns +`{ inline, longForm }`. Every required element is read from an explicit field; a missing field **throws a +named error** identifying the field. Completeness is enforced by construction rather than by review: the +required-element list is a constant, and assembly validates the built payload against it before +returning, so adding a required element without producing it fails the tests. + +**The long form is spawn machinery, not a paraphrase of it.** `longForm` is the output of +`buildPromptFromTemplate(config, protocol, templateContext)` — the same function the fresh-launch spawn +path uses (`spawn.ts:470`) — wrapped in a reset header and the state-file pointer. The `TemplateContext` +is reconstructed from phase 4's resolved context: protocol, mode, project id, spec and plan paths, issue +number and body. The porch re-entry wording in `inline` reuses `buildResumeNotice()` **verbatim** rather +than restating it, so there is exactly one copy of that text. + +This is the concrete discharge of architect directive 4 ("re-inject phase context the way `--resume` +does"): the builder receives the same protocol/phase framing a fresh launch would deliver, through a file +rather than a prompt argument. The first draft's "registry → payload" was too vague for an R3-critical +path — a fair CMAP catch. + +**`inline`** (the message, kept compact for the paced channel): reset notice; role frame as an *identity +block* — that the recipient is a builder and which role document governs it, **not** the role's full +text (under the Claude harness the role lives in `--append-system-prompt` and survives `/clear`); +protocol and mode; project id and issue; worktree and branch; `porch next` re-entry for porch-driven +lanes; the `.builder-state.md` pointer with a read-in-full instruction; the addendum. + +**`longForm`** (written to `.builder-reorient.md`): the full assembled re-orientation including any +protocol/phase context too large to inline, plus a pointer back to the state file. + +Both worktree artifacts use the `.builder-` prefix so `afx cleanup`'s scaffold classification +(`cleanup.ts`) continues to treat the worktree as clean, and both are untracked so `porch done`'s +staged-file sweep cannot pick them up. + +**Addendum sources**: `--note ` inline; `--file ` read from the **caller's** filesystem, +exactly as `afx send --file` does (48KB cap reused). The worktree-containment rule applies only to an +override of the *state-file* path, validated in phase 5. + +#### Acceptance Criteria +- [ ] Assembled inline payload contains identity block, protocol, mode, project/issue, worktree, branch, + state-file pointer, and — for a porch-driven lane — the `porch next` instruction. +- [ ] A missing registry field throws a named error rather than emitting a partial frame. +- [ ] The role's full text is **not** inlined. +- [ ] `--note` and `--file` content appears in the payload. +- [ ] Assembly is pure: no terminal writes, no builder contact. + +#### Test Plan +- **Unit**: completeness assertion per element; one test per missing-field abort; a test that adding a + required element without producing it fails; `--file` reads from the caller's filesystem; addendum + placement. +- **Manual**: inspect `--dry-run` output against a live builder record in phase 5. + +#### Rollback Strategy +Delete the module; nothing imports it until phase 5. + +#### Risks +- **Risk**: the inline payload grows past a comfortable paced-write size. + - **Mitigation**: a test asserts an upper bound on inline line count; overflow belongs in `longForm`. + +--- + +### Phase 6: Reset orchestrator + CLI wiring +**Dependencies**: Phases 1, 2, 3, 4, 5 + +#### Objectives +- Compose the verified parts into the `afx reset` state machine, with R1 and R4 enforced by ordering that + is directly testable. + +#### Deliverables +- [ ] `packages/codev/src/agent-farm/commands/reset/index.ts` (new) — the state machine. +- [ ] `packages/codev/src/agent-farm/cli.ts` — register `reset [builder]` with `--note`, `--file`, + `--dry-run`, `--interrupt-first`, `--timeout`, `--min-bytes`, `--quiet-window`. +- [ ] `packages/codev/src/agent-farm/types.ts` — `ResetOptions`. +- [ ] Tests: `packages/codev/src/agent-farm/__tests__/spec-1273-reset-orchestrator.test.ts`. + +#### Implementation Details + +The orchestrator is a pure state machine over injected ports (`clock`, `fs`, `writeToTerminal`, +`readTerminalInfo`, `sendMessage`), so every ordering invariant is testable without Tower, a PTY, or a +live builder. It records an ordered **step log** of every externally-visible action; the invariant tests +assert over that log, which is what makes "impossible by construction" checkable rather than aspirational. + +Sequence: + +1. **Resolve and validate** — reuse `afx send`'s resolver and workspace detection; read the builder record + for worktree/branch/issue; resolve protocol, mode and harness via phase 4; confirm the terminal exists + and is writable; require `supportsContextReset`. A non-Claude harness **aborts loudly**, naming the + harness — no substituted mechanism (fail fast). +2. **Assemble (R1)** — build the re-orientation via phase 5 and write `.builder-reorient.md`. Any failure + aborts here, with the builder untouched. **Nothing destructive can precede this step.** +3. **Optional `--interrupt-first`** — for a builder already wedged mid-turn, send ESC via phase 1's path + before the save request. Default off: the default path assumes an addressable builder and does not + guess. +4. **Request the save** — send the phase 3 template. +5. **Await receipt (R2)** — poll until accepted; on timeout abort non-zero, naming the failing gate, with + the builder's context intact. +6. **Quiesce (R4)** — bounded wait for `lastDataAt` to be older than the quiet window; if not reached, + **exactly one** ESC escalation (legal only here, after the R2 receipt); bounded wait again; if still + not quiet, abort non-zero **without clearing**. No third attempt, no "clear anyway". +7. **Clear** — write `/clear` raw. +8. **Confirm (best-effort)** — scan terminal output; an unconfirmed clear is *reported as unconfirmed*, + never as success, and never changes the ordering. +9. **Re-orient** — deliver the inline payload assembled in step 2. +10. **Report** — print each step with its verified evidence: nonce receipt, state-file size, quiescence, + clear-confirmation status, delivery. + +`--dry-run` prints the save request and both payload parts and performs **zero** writes to the builder — +making R1 and R3 auditable by inspection, as the spec requires. + +A state-file path override, if offered, is resolved and validated to remain inside the target builder's +worktree. + +#### Acceptance Criteria +- [ ] Happy path: request → verified receipt → quiescence → clear → complete re-orientation → report. +- [ ] **R1**: no `/clear` in the step log unless assembly succeeded first; a test that inverts the order fails. +- [ ] **R2**: never-appearing, stale-nonce, undersized and still-growing files each abort with **no** + `/clear` in the step log. +- [ ] **R4**: never-quiet terminal → exactly one ESC → abort non-zero, no `/clear`; and the ESC never + precedes the R2 receipt. +- [ ] Unsupported harness and non-writable terminal abort loudly with no terminal writes. +- [ ] `--dry-run` produces zero writes to the builder. +- [ ] `--note` / `--file` reach the payload; `--interrupt-first` places ESC before the save request. +- [ ] Addressing parity with `afx send`. +- [ ] A worktree carrying `.builder-state.md` and `.builder-reorient.md` is still reported clean by + `afx cleanup`'s scaffold check. + +#### Test Plan +- **Unit** (over the step log, ports injected): scenarios 1–14 and 9a/9b/9c from the spec, each asserting + both the outcome and the *absence* of forbidden actions. +- **Integration**: spec scenario 14a — wedged builder → `--interrupt-first` → turn breaks → save request + read → flow completes. Uses the existing terminal test harness; skipped-with-annotation only if the + harness cannot simulate a wedged turn, and called out in the review if so. +- **Manual**: run `afx reset` against a real builder in this workspace and confirm end-to-end behaviour — + "it compiled" is not "it works", and this is the headline user path. + +#### Rollback Strategy +Revert the command and its CLI registration; phases 1–5 remain independently valuable (interrupt ships, +`lastDataAt` is additive, the reset modules are unreferenced). + +#### Risks +- **Risk**: `/clear` cannot be positively confirmed across Claude Code versions. + - **Mitigation**: confirmation is report-only by design; the re-orientation is correct even if the clear + silently failed (worst case: no context saving, no data loss). +- **Risk**: quiescence heuristics misfire on an unusually quiet-but-busy turn. + - **Mitigation**: bounded waits and a single escalation cap the damage at "abort without clearing" — + the safe direction; `--quiet-window` is tunable. +- **Risk**: the manual end-to-end run resets a real builder in this workspace. + - **Mitigation**: exercise `--dry-run` first, then run against a disposable builder, not a live lane. + +--- + +### Phase 7: Wait discipline and command documentation +**Dependencies**: Phase 1 (the guidance names `afx interrupt`) + +#### Objectives +- Move wait discipline out of architect lore into the builder-facing role document. +- Make both commands discoverable at the point of use. + +#### Deliverables +- [ ] `codev-skeleton/roles/builder.md` — **primary copy** of the wait-discipline section. +- [ ] `codev/roles/builder.md` — the same content as a purely additive block. +- [ ] `codev-skeleton/resources/commands/agent-farm.md` and `codev/resources/commands/agent-farm.md` — + `afx reset` and `afx interrupt` reference entries. +- [ ] **Both** skill trees: `.claude/skills/afx/SKILL.md` **and** `.codex/skills/afx/SKILL.md`. The repo + maintains parallel Claude and Codex skill trees (verified: both exist with identical skill sets); + updating only the Claude one would leave Codex-driven agents unable to discover the commands — + a CMAP catch on the first draft. + +#### Implementation Details + +The section sits alongside the existing "When You're Blocked" guidance, which today covers only +*architect-blocked* situations and is silent on *external-artifact-blocked* ones. Three rules, each with +the reasoning that makes it stick: + +- A wait is a **claim that a producer exists** — verify the producing process is alive before waiting on + its artifact. (In the incident, the producer had already died, so the wait could never succeed.) +- Waits on external artifacts run as **tracked background tasks that end the turn** — re-invocation on + completion keeps the lane moving *and* keeps the builder addressable. +- **Never chain foreground poll loops.** A turn that never ends makes every `afx send` — including the + order to stop — queue unread. +- Plus the escape hatch: if you are wedged anyway, the architect can reach you with `afx interrupt`. + +**Placement rationale** (architect directive 5): the skeleton is the primary copy; the `codev/` change is +a small additive block. Because `codev/roles/builder.md` shadows the skeleton via the four-tier resolver, +skeleton-only would leave *this* workspace's builders without the guidance — so the mirror is required, +but kept additive so it rebases cleanly if spir-1252 removes `codev/` shadow copies. + +The guidance is scoped to the **builder role** as its single owning surface and deliberately **not** +fanned out into each protocol's prompts — protocol-prompt ownership is spir-1252's subject, and +duplicating guidance across surfaces is what that ownership map exists to prevent. + +#### Acceptance Criteria +- [ ] The three rules and the `afx interrupt` escape hatch appear in both role documents. +- [ ] Command reference documents both commands with their flags in both trees. +- [ ] Both `.claude/skills/afx/SKILL.md` and `.codex/skills/afx/SKILL.md` list the new commands. +- [ ] A repo-wide grep confirms no stale references and that skeleton/`codev` copies agree + (per the standing "grep BOTH trees" lesson). + +#### Test Plan +- **Unit**: a docs test asserting the section exists in both role files with equivalent content. +- **Manual**: read the rendered section as a cold builder would. + +#### Rollback Strategy +Revert the doc commits; no code depends on them. + +#### Risks +- **Risk**: collision with spir-1252's prompt-surface restructuring. + - **Mitigation**: skeleton-primary, additive `codev/` block; check spir-1252's thread before the final + rebase and coordinate via `afx send` if the surfaces have moved. + +## Dependency Map + +``` +Phase 1 (afx interrupt) ──────────────────────────┐ +Phase 2 (lastDataAt) ─────────────────────────────┤ +Phase 3 (receipt gate) ───────────────────────────┼──→ Phase 6 (orchestrator + CLI) +Phase 4 (context resolution) ──→ Phase 5 (reorient assembly) + │ + └── Phase 1 ──→ Phase 7 (docs, needs Phase 1's command name) +``` + +Phases 1–4 are mutually independent and individually shippable. Phase 5 depends only on phase 4's +resolved-context type. Phase 6 is the only integration point. + +## Resource Requirements + +### Development Resources +- **Engineers**: one builder; familiarity with the afx/Tower message path and the terminal layer. +- **Environment**: local Tower for the manual verification in phases 1 and 5. + +### Infrastructure +- No database changes (no schema migration — the builders table already carries every field needed). +- No new services, no new dependencies. +- No configuration changes; all tunables are CLI flags with defaults. + +## Integration Points + +### External Systems +None. + +### Internal Systems +- **Tower `POST /api/send`** — phase 1 adds the `escape` option; phases 5 uses it and the normal path. + *Fallback if unavailable*: none by design — Tower down is a loud failure, as with `afx send`. +- **Tower `GET /api/terminals/:id`** — phase 2 adds `lastDataAt`; phase 5 reads it for R4. +- **Message resolver (`resolveTarget`)** — reused unchanged by both commands; no second addressing path. +- **Builder registry (`global.db`)** — read-only source for the re-orientation frame. +- **Harness abstraction** — gates reset on in-session-clear support. +- **porch** — read-only; reset never writes `status.yaml`, it re-orients *toward* `porch next`. +- **`afx cleanup`** — the `.builder-` prefix keeps worktrees classified clean. + +## Risk Analysis + +### Technical Risks +| Risk | Probability | Impact | Mitigation | Owner | +|---|---|---|---|---| +| `escape` branch perturbs the normal send path | Low | High | Guarded early return before formatting/buffering; existing send tests run untouched | Builder | +| `/clear` coupling breaks on a future Claude Code version | Low | Medium | Single documented coupling point, harness-gated; failure is visible in the report, not silent | Builder | +| ESC delivery breaks if `handleSend` trimming changes | Low | High | Explicit regression test pinning the invariant (phase 1) | Builder | +| Quiescence heuristic misfires | Medium | Low | Bounded waits, one escalation, abort-without-clearing is the safe direction; tunable window | Builder | +| State file passes the structural gate but is useless | Medium | High | Out of scope for programmatic checking by design; mitigated by the checklist, reported size, `--dry-run` | Architect | +| Manual end-to-end run disrupts a live builder | Low | Medium | `--dry-run` first; target a disposable builder | Builder | + +### Schedule Risks +| Risk | Probability | Impact | Mitigation | Owner | +|---|---|---|---|---| +| spir-1252 moves the doc surfaces mid-flight | Medium | Low | Skeleton-primary, additive `codev/` block; re-check 1252's thread before final rebase | Builder | +| Phase 5 integration test cannot simulate a wedged turn | Medium | Low | Fall back to unit-level ordering coverage and call the gap out explicitly in the review | Builder | + +## Validation Checkpoints + +1. **After Phase 1**: `afx interrupt` unwedges a real builder mid-turn (manual), and `afx send` is unchanged. +2. **After Phase 2**: `lastDataAt` advances and settles as expected against a live terminal. +3. **After Phase 4**: resolution run against this workspace's live builders returns the protocol and mode + each was actually spawned with — the check that would have caught the NULL `protocol_name` assumption. +4. **After Phase 5**: `--dry-run`-shaped payload inspection shows a complete frame for a real builder. +5. **After Phase 6**: full `afx reset` against a disposable builder — the headline path, run for real. +6. **Before PR**: `pnpm build` + `pnpm test` green; both trees grepped for consistency; every spec success + criterion walked item by item. + +## Monitoring and Observability + +### Metrics to Track +Not applicable — these are interactive CLI commands, not services. + +### Logging Requirements +- Tower logs each send at existing levels; the `escape` path logs like the interrupt path. +- `afx reset` prints a step-by-step report to stdout; unconfirmed steps are labelled unconfirmed, never + reported as success. + +### Alerting +None. + +## Documentation Updates Required +- [ ] Wait-discipline section in both builder role documents (phase 6) +- [ ] `afx reset` / `afx interrupt` entries in the agent-farm command reference, both trees (phase 6) +- [ ] `.claude/skills/afx` reference (phase 6) +- [ ] Review file at `codev/reviews/1273-builder-context-reset-should-b.md` (Review phase) + +## Post-Implementation Tasks +- [ ] Manual end-to-end verification of both commands against a real builder +- [ ] Confirm `afx cleanup` still classifies a reset worktree as clean +- [ ] Candidate lessons for `lessons-learned.md`: "a wait is a claim that a producer exists", and + "make destructive steps depend on a receipt, not on operator eyeballing" — routed by tier at review + +## Expert Review + +**Date**: 2026-07-28 +**Models Consulted**: Gemini (agy), GPT-5.4 Codex, Claude +**Verdicts (iteration 1)**: Gemini APPROVE (HIGH), Claude APPROVE (HIGH), Codex REQUEST_CHANGES (HIGH) + +**Key Feedback and Plan Adjustments**: + +- *Codex — phase 1 omits the client surface.* `TowerClient.sendMessage` (`packages/core/src/tower-client.ts`) + has no `escape` option; the plan claimed end-to-end reuse without listing it. **Added to phase 1 + deliverables**, with a note that the `agent-farm/lib` copy is a pure re-export. +- *Codex — mode/harness are not persisted.* Verified, and the gap is larger than reported: + `builders.protocol_name` is NULL for **spec-type builders**, so the DB does not even carry the + protocol for SPIR/ASPIR lanes. **Added a "Where reset's context actually comes from" section and a new + phase 4** that resolves protocol/phase/mode/harness from the sources that hold them, each chain ending + in a loud abort. Explicitly declined to add a `mode` DB column: it would be NULL for every running + builder and would duplicate a fact the worktree already holds. +- *Codex — re-orientation is too hand-wavy for an R3-critical path.* **Anchored concretely**: the long + form is `buildPromptFromTemplate`'s output — the same function the fresh-launch path uses — and the + porch re-entry text reuses `buildResumeNotice()` verbatim. +- *Codex — only the Claude skill tree named.* Verified `.codex/skills/afx/` exists too. **Both trees** + added to phase 7. +- *Claude — harness capability gate not deliverable-listed.* **Added** `supportsContextReset` on + `HarnessProvider` to phase 4's deliverables. +- *Claude — an existing `pty-last-data-at.test.ts` already covers the field.* **Changed phase 2** to + extend it rather than create a parallel file. + +Gemini approved with no issues. + +## Approval + +ASPIR: plan-approval is auto-approved; the `pr` gate remains human-approved. + +- [ ] Expert AI Consultation Complete + +## Change Log +| Date | Change | Reason | Author | +|---|---|---|---| +| 2026-07-28 | Initial plan | Spec 1273 approved (ASPIR auto-approval after CMAP iteration 1) | Builder aspir-1273 | +| 2026-07-28 | Added phase 4 (context resolution); anchored re-orientation to `buildPromptFromTemplate`/`buildResumeNotice`; added the core client surface to phase 1; both skill trees in phase 7; `supportsContextReset` capability; phase 2 extends the existing test | Plan CMAP iteration 1 — Codex REQUEST_CHANGES (4 issues), Claude non-blocking notes | Builder aspir-1273 | + +## Notes + +- **Why the invariants live in module boundaries**: the spec's bar is "impossible by construction". A + comment saying "assemble before clearing" is not construction. Making assembly a separate module that + the orchestrator must successfully call before it can produce a clear step — and asserting over an + ordered step log — is. +- **PR strategy**: per the spawn prompt, phases ship as git commits on one branch; a single PR opens + during/after phase 6 unless the architect requests one earlier. +- Reset never writes `status.yaml`. Any temptation to have it advance porch state is out of bounds. + +--- + +## Amendment History + + diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_2-iter1-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_2-iter1-rebuttals.md new file mode 100644 index 000000000..204525b1d --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_2-iter1-rebuttals.md @@ -0,0 +1,77 @@ +# Rebuttal — Phase 2 (Quiescence observability), iteration 1 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +Codex's single issue is accepted and fixed. Notably, **two reviewers reached opposite conclusions on +exactly this point**, so the reasoning for siding with Codex is recorded below rather than left implicit. + +--- + +## Codex — REQUEST_CHANGES + +### Issue: "Missing route/integration coverage for the actual wire contract" + +**Accepted.** The four tests I added exercised `session.info` directly and stopped there. That pins the +field on the *class*; it pins nothing about the *response*. The entire reason phase 2 exists is that +`afx reset` reads this value over HTTP — the class already had a working `lastDataAt` getter since +Spec 467, and if in-process access were sufficient the phase would have been unnecessary. + +Codex is also holding me to my own written criteria, which is the stronger form of the argument: + +- Acceptance criterion, verbatim: *"`GET /api/terminals/:id` includes `lastDataAt` as an epoch-ms number."* +- Test plan, verbatim: *"**Integration**: terminal info endpoint returns the field."* + +Neither was satisfied. A phase whose acceptance criterion names an endpoint needs a test that calls it. + +**Changed** — two tests added to `tower-routes.test.ts` under a new +`GET /api/terminals/:id (Spec 1273 — lastDataAt on the wire)` block: + +1. The serialised response carries `lastDataAt` as a `number` with the expected value. +2. An unknown terminal returns 404 `NOT_FOUND` — so a missing field can never be mistaken for a + successful response that merely lacks it. + +95 route tests pass. + +--- + +## Claude — APPROVE, but explicitly contradicts the above + +Claude reviewed the same gap and concluded the opposite: + +> The plan mentioned an integration test ("terminal info endpoint returns the field"), but the endpoint +> is a pure `JSON.stringify(session.info)` passthrough — the unit test pinning the field's presence on +> `info` is the right level of coverage. **No gap here.** + +**I sided with Codex.** Claude's premise is factually correct — the handler today is +`res.end(JSON.stringify(session.info))` — but the conclusion does not follow: + +1. **"Pure passthrough" is a property of today's code, not of the contract.** A future change that + selects or reshapes fields at the route (redaction, a projection for a lighter list payload, a + version envelope) would break the wire contract while every `session.info` unit test stayed green. + The consumer depends on the response, so the response is what deserves the pin. +2. **The acceptance criterion names the endpoint.** Declining to test it means shipping a phase whose + stated criterion is unverified, and later readers would have to re-derive that the omission was + deliberate. +3. **The cost is two cheap tests.** When one reviewer says a test is missing and another says it is + redundant, and the test is nearly free, the asymmetry favours writing it: the downside of a + redundant test is trivial, the downside of an unpinned wire contract is a silent break in the signal + R4 depends on before it types `/clear` into a live builder's terminal. + +Recording the disagreement rather than quietly following the approving reviewer — the majority verdict +was APPROVE, and it would have been easy to ship on that. + +Claude's other observation, that the `tower-client.ts` diff also contains phase 1's `escape` option, is +correct and expected: both phases touch that file and phase 1 landed first. + +--- + +## Gemini — APPROVE + +No issues raised. + +--- + +## Net effect + +Two route-level tests added; no production code changed. The phase's stated acceptance criterion is now +actually verified. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_3-iter1-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_3-iter1-rebuttals.md new file mode 100644 index 000000000..847909be9 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_3-iter1-rebuttals.md @@ -0,0 +1,66 @@ +# Rebuttal — Phase 3 (Reset receipt gate), iteration 1 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +Both findings accepted, neither disputed. + +--- + +## Codex — REQUEST_CHANGES + +### Issue: "`stateFilePath` is POSIX-only and will generate incorrect paths for Windows worktrees" + +**Accepted.** The helper built the path by hand: + +```ts +return `${worktreePath.replace(/\/+$/, '')}/${fileName}`; +``` + +On Windows that yields `C:\repo\wt\/.builder-state.md` — the trailing-separator strip only matches +forward slashes, and the joiner is a hardcoded `/`. Windows is a supported platform here (the VSCode +integration opens worktrees in Explorer), so this is a real defect, not a theoretical one. + +**Why it matters more than a cosmetic path bug**: this exact string is interpolated into the save +request and handed to the builder verbatim as "write your state here". A malformed path means the +builder writes to one location while the gate stats another — so the file never appears, R2 times out, +and the reset aborts. The failure would present as "the builder ignored the request", sending the +architect to debug builder behaviour rather than a path bug. + +**Changed** — `stateFilePath` now uses `path.join`, which also collapses redundant separators (so the +trailing-slash case is handled by the platform rather than a regex). Two tests added: + +1. Platform-join equivalence, asserting against `path.join` so the test stays meaningful on whichever + platform CI runs, plus explicit `not.toContain('\\/')` and `not.toContain('//')` guards. +2. No doubled separator for `'/a/b'`, `'/a/b/'` and `'/a/b//'`. + +--- + +## Claude — APPROVE, one minor suggestion + +### Suggestion: the checklist test asserts 6 of 7 items + +**Accepted.** The test named "asks for every item on the cold-reader checklist" omitted +`'position in the protocol'` (item 2). The save request itself contains it, so this was a test gap, not +a functional one — but a test that claims "every item" and checks six of seven is exactly the kind of +thing that lets a later edit silently drop a checklist item. + +This matters more than the usual missing-assertion nit because the gate is **structural by design**: +`verifyReceipt` checks nonce, size and stability, and deliberately does not attempt to score prose. The +request wording is therefore the *only* mechanism driving state-file quality, and these assertions are +the only thing pinning that wording. Item 2 is also the item that tells a cold reader where it is in the +protocol — the one whose absence would leave a freshly-reset builder unsure what porch expects next. + +**Changed** — assertion added; the test now covers all seven. + +--- + +## Gemini — APPROVE + +No issues raised. + +--- + +## Net effect + +One real cross-platform defect fixed in production code, three tests added (two portability, one +checklist completeness). 26 tests pass. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_4-iter1-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_4-iter1-rebuttals.md new file mode 100644 index 000000000..a7c5641f4 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_4-iter1-rebuttals.md @@ -0,0 +1,96 @@ +# Rebuttal — Phase 4 (Builder context resolution), iteration 1 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +All four findings accepted — two blocking from Codex, two non-blocking from Claude. Notably, Codex and +Claude **independently identified the same first defect**, which raised my confidence that it was real +rather than a reviewer preference. + +--- + +## Codex — REQUEST_CHANGES + +### Issue 1: "The resolved context does not carry the full phase-4 deliverable shape" + +**Accepted.** The plan states phase 4 resolves `{ protocol, phase, mode, harness, specName, planPath, issue }`. +`ResolvedBuilderContext` carried protocol, mode, harness, porch and a pass-through `issueNumber` — +`specName` and `planPath` were simply absent, and `issue` was forwarded rather than resolved. + +This is not a cosmetic shape mismatch. Phase 5's whole design is that the long-form re-orientation is +`buildPromptFromTemplate`'s output, which needs a `TemplateContext` carrying the spec and plan paths. +Leaving those out would have pushed the derivation into phase 5, where it would sit next to prompt +assembly instead of next to the other worktree-reading logic — and phase 5's job is to *fail loudly on a +missing field*, not to go looking for one. + +**Changed** — `artifactPaths()` derives them from the porch project name (porch names its project dir +`-`, and spec/plan files share that stem): + +- `specName`, `specPath`, `planPath` added to `ResolvedBuilderContext`. +- Paths are returned **only when the file exists**, else null. A pointer to a nonexistent plan would send + a freshly-reset builder — one with no memory to cross-check against — chasing a ghost. +- All three are null on a non-porch lane, where no naming convention applies. +- `issueNumber` now resolves as `issueNumber ?? porch.projectId`: issue-driven protocols name the porch + project after the issue, so the project id is correct when the registry row carries none. + +### Issue 2: "Harness lookup is hard-coded to BUILTIN_HARNESSES, bypassing custom providers" + +**Accepted.** `.codev/config.json` can define custom harnesses, and `resolveHarness` honours them. My +scanner only knew builtin names, so a builder launched with a custom harness failed as *"no recognisable +launch command"*. + +The user-visible consequence is what makes this worth fixing: that message sends the project to debug its +`.codev/config.json` for a config that is in fact correct. The accurate refusal is *this harness cannot +clear context in-session*. + +**Changed** — `harnessFromLaunchScript` and a new `harnessProviderFor` both accept the custom-harness map, +so a custom harness is recognised, mapped to a real provider via `buildCustomHarnessProvider`, and then +capability-checked. Because `buildCustomHarnessProvider` does not set `supportsContextReset`, custom +harnesses remain unsupported by default — the safe direction. Letting one *declare* support would be a +one-field addition to `CustomHarnessConfig`; deliberately out of scope, and noted in the code. + +--- + +## Claude — APPROVE, three non-blocking observations (all acted on) + +### 1. `specName` / `planPath` absent + +Same defect as Codex issue 1, found independently. Fixed as above. Claude judged it non-blocking on the +grounds that phase 5 could derive them itself; I treated it as blocking because the plan assigns the +derivation to phase 4 and because keeping worktree-reading in one module is what lets phase 5 be a pure +complete-or-abort assembler. + +### 2. Double `status.yaml` read + +**Accepted.** `protocolFromStatus` and `readPorchContext` each scanned the project dirs and read the same +file. Claude called it style; I fixed it for a correctness reason it did not mention: two independent +scans could in principle disagree about *which* status.yaml is authoritative if a worktree ever held more +than one project directory. `protocolFromStatus` now delegates to `readPorchContext`, and `protocol` moved +onto `PorchContext`, so there is a single answer by construction. + +### 3. `harnessFromLaunchScript` could false-positive on a conditional + +**Accepted, and this was the most valuable observation of the round.** Claude noted that a future launch +script containing `if [ "$HARNESS" = "codex" ]` would match `codex` by substring and return the wrong +harness. + +That is not a harmless misread. Naming the wrong harness either refuses a builder that *could* be reset, +or — the dangerous direction — approves typing `/clear` into a builder whose harness has no in-session +reset, where the keystrokes land as literal text in a live agent's prompt. + +**Changed** — detection now matches on **command position** rather than searching the line. A new +`commandNameOf()` strips shell control keywords (`while`, `if`, `exec`, …) and leading `VAR=value` +assignments, takes the first token, and basenames it. Five tests pin the behaviour: a harness named inside +a conditional, a variable assignment, an absolute path, an `exec` + env-var prefix, and a comment. + +--- + +## Gemini — APPROVE + +No issues raised. + +--- + +## Net effect + +Two production defects fixed (incomplete context shape, custom-harness blindness), one latent +false-positive surface closed, one duplicate scan removed. Tests 24 → 38. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter1-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter1-rebuttals.md new file mode 100644 index 000000000..205dbb73c --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter1-rebuttals.md @@ -0,0 +1,101 @@ +# Rebuttal — Phase 5 (Re-orientation assembly), iteration 1 + +**Verdicts**: Gemini APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) · Claude REQUEST_CHANGES (HIGH) + +All five findings accepted. One point is a plan requirement I wrote and then did not follow. + +**A note on Claude's blocking issue**: it reviewed the working tree while I was already applying Codex's +fixes, so it observed an intermediate state where `buildResumeNotice` had become required but the test +helper had not yet been updated. Its finding was therefore correct as observed and is resolved by the +same change. Recorded here rather than waved away, because "the reviewer saw a transient state" is +exactly the excuse that hides a real breakage — the 20 failures were real at the moment they were seen. + +--- + +## Codex — REQUEST_CHANGES + +### Issue 1: "Assembly permits a partial porch/issue frame" + +**Accepted.** `REQUIRED_INLINE_MARKERS` covered protocol, mode, worktree, branch, state file and +long-form pointer — but not `Project:`, `Issue:` or `porch next`. Those were produced when the context +happened to carry them and silently skipped when it did not, which is precisely the "optional, therefore +omittable" shape R3 forbids. + +The gap mattered because it was invisible: a porch-driven builder could be re-oriented without its own +project identity, and nothing in the code or the tests would object. A reset builder that cannot name its +project cannot find its spec, plan or status file. + +**Changed** — added `conditionalInlineMarkers(context)`: markers that are required *when the lane +supplies the corresponding fact*. A porch lane must carry `Project:` and `porch next`; any lane with a +known issue must carry `Issue:`. Validation now checks `[...REQUIRED, ...conditional]`. This keeps the +non-porch case honest — a task builder genuinely has no project — while making omission impossible +wherever the fact exists. + +### Issue 2: "Restates the porch re-entry instruction instead of reusing `buildResumeNotice()` verbatim" + +**Accepted, and this one is mine twice over** — the plan I wrote says "the porch re-entry wording in +`inline` reuses `buildResumeNotice()` **verbatim** rather than restating it, so there is exactly one copy +of that text", and I then wrote my own sentence. + +Codex names the concrete cost: my restatement dropped the `porch init` fallback that `buildResumeNotice` +carries for when porch reports "not found". A builder that hits that case after a reset — with no +conversation history to fall back on — would have been left without the recovery instruction. + +**Changed** — added a `ResumeNoticePort`, wired in phase 6 to the real `buildResumeNotice`. The long form +embeds its output **verbatim**, so there is one copy of the text and the `porch init` fallback survives. +The inline frame keeps a one-line `porch next` pointer and refers to the long form for the full guidance. + +*Deviation from the plan's letter, stated plainly*: the plan said the verbatim reuse would sit inline. It +sits in the long form instead. `buildResumeNotice` opens with "This is a **resumed** builder session", +which is false after a reset — the session was cleared, not resumed — and it is seven lines in a frame +whose fitness for the paced message channel is itself a tested constraint. Putting it in the long form +preserves the property the plan was actually protecting (one copy, no drift, fallback intact) without +telling a freshly-reset builder something untrue about its own state. A porch lane with no notice +available now **aborts**. + +### Issue 3: "Test coverage misses the invariant gap" + +**Accepted.** The suite asserted against the marker list, and the list was the thing that was wrong — so +it would have passed with project, issue and porch re-entry all absent. A test that validates against the +same incomplete constant it is meant to police is not coverage. + +**Changed** — tests now assert the conditional markers directly (porch lane requires `Project:` and +`porch next`; issue-bearing lane requires `Issue:`; non-porch lane requires neither), plus rendered-output +assertions for the project name and issue number, and an abort case for a porch lane with no notice. + +--- + +## Claude — REQUEST_CHANGES + +### Blocking: "20/28 tests fail — helper omits `buildResumeNotice`" + +**Correct as observed, and resolved by the same change.** The `assemble()` helper and three direct +`assembleReorientation` call sites now supply a `resumeNoticePort` fixture that includes the `porch init` +fallback, so the tests exercise the real reuse path rather than a stub that merely satisfies the type. +35 tests pass. + +### Minor 1: "`plan.name` not asserted, asymmetric with `spec`" + +**Accepted.** The plan assertion checked only `path`. Reusing `specName` for the plan is intentional — +porch names spec and plan from the same stem — and an asymmetric assertion would let that convention +break unnoticed. Now asserts the whole object, symmetric with `spec`. + +### Minor 2: "No test that the resume notice appears in `longForm`" + +**Accepted.** Added two: the notice appears verbatim in the long form for a porch lane (asserting the +`porch init` fallback text specifically, since dropping it was the actual defect), and is absent on a +non-porch lane. + +--- + +## Gemini — APPROVE + +No issues raised. Worth noting it approved a frame with the porch/issue gap in it, which is why the +dissent was worth taking seriously rather than counting verdicts. + +--- + +## Net effect + +One real invariant hole closed (conditional markers), one single-source violation fixed with the dropped +`porch init` fallback restored, three test gaps closed. Tests 28 → 35. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter2-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter2-rebuttals.md new file mode 100644 index 000000000..7b4c4e266 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter2-rebuttals.md @@ -0,0 +1,73 @@ +# Rebuttal — Phase 5 (Re-orientation assembly), iteration 2 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +Accepted in full. Codex's finding is the most consequential defect caught in this project so far, and it +survived two complete review rounds before anyone saw it. + +--- + +## Codex — REQUEST_CHANGES + +### Issue: "The long form does not reconstruct issue-backed spawn context" + +**Verified against the templates before acting, and accepted.** `SpawnPromptPort`'s context had no +`issue` field, so `buildLongForm` never passed issue metadata to the spawn prompt. But the builder-prompt +templates for every issue-driven protocol render it: + +- `codev-skeleton/protocols/aspir/builder-prompt.md` — `{{issue.number}}`, `{{issue.title}}`, `{{issue.body}}` +- same in `spir`, `air`, `bugfix`, `pir` + +**Why this is worse than a missing field.** On BUGFIX and AIR the issue body is not supporting context — +it *is* the spec. There is no `codev/specs/` artifact on those lanes. So a reset builder would have +received a long form that looked complete, carried the correct protocol framing, named the right issue +number, and silently omitted the requirements it was implementing. It would then have continued from its +state file alone, confident and under-briefed. + +That is precisely the class of failure this whole feature exists to prevent, and my own R3 machinery did +not catch it: `REQUIRED_INLINE_MARKERS` validates the *frame I thought of*, not the *inputs the template +consumes*. Completeness checks only cover the shape you enumerated. Recorded for the review's lessons. + +**Changed**: + +- `SpawnPromptPort` context gains `issue?: { number, title, body }`. +- `AssembleOptions` gains an `IssuePayload`, fetched by the orchestrator so this module stays pure and + free of I/O. +- `buildLongForm` forwards it when present. + +**Judgement call on the absent case, stated explicitly.** When the lane is issue-backed but the issue +could not be fetched, reset does **not** hard-fail. A forge outage should not stop an architect resetting +a wedged builder — that would make the recovery tool depend on the availability of an unrelated service, +at exactly the moment things are already going wrong. Instead the long form carries a visible gap marker +naming the issue and `gh issue view <n>` as the recovery. + +This is stated degradation with an instruction, not a silent fallback. The distinction matters against +the repository's "no fallbacks" rule: nothing alternative is substituted and nothing is concealed — the +builder is told what is missing and how to get it. Silent omission is the dangerous version, because a +BUGFIX builder would infer requirements from whatever framing survived. + +### Issue: "Tests do not catch that gap" + +**Accepted.** The forwarding tests asserted protocol, mode, spec and plan reached the prompt context, and +stopped there — so they would have passed with issue context absent forever. + +**Changed** — five tests added: issue forwarded with all three fields; issue absent from the context when +none supplied; gap marker present when the lane has an issue but no payload; gap marker absent when the +payload is supplied; gap marker absent on a lane with no issue at all. + +--- + +## Gemini — APPROVE + +No issues raised. + +## Claude — APPROVE + +No issues raised. Both approvers passed over the issue-metadata gap, as did all three reviewers in +iteration 1 — the defect was only visible by reading the protocol templates rather than the phase diff. + +--- + +## Net effect + +One substantive gap closed in the phase's core claim (spawn-equivalence), five tests added. Tests 35 → 40. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter3-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter3-rebuttals.md new file mode 100644 index 000000000..7fdf682e2 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter3-rebuttals.md @@ -0,0 +1,66 @@ +# Rebuttal — Phase 5 (Re-orientation assembly), iteration 3 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +Both of Codex's points accepted. Both hold the frame to wording I wrote in the spec and then did not +implement. + +--- + +## Codex — REQUEST_CHANGES + +### Issue 1: "The identity block never says which role document governs the builder" + +**Accepted.** The spec's description of the inline identity block is explicit: it must convey *"that the +recipient is a builder **and which role document governs it**"*. The frame said: + +> You are a Builder (your role document governs you and is still in effect). + +That satisfies the first half and gestures at the second. The gesture is worthless to the actual reader: +a builder whose conversation has just been cleared has no way to resolve "your role document" to a file. +Naming it is the entire point of the requirement. + +**Changed** — the identity block now names `.builder-role.md` at the worktree root, which is the concrete +artifact the harness injected at spawn (rather than a `codev/roles/...` path that resolves through the +four-tier chain and may not exist on disk in the worktree). `.builder-role.md` was added to +`REQUIRED_INLINE_MARKERS`, so it cannot be dropped by a later edit without failing assembly. + +### Issue 2: "Only the derived directory stem is emitted, not explicit project id" + +**Accepted.** The frame carried `Project: 1273-builder-context-reset-should-b` — the porch project +directory name. The id is *inside* that string, but never labelled. + +Concretely: `porch status <id>` and `porch next <id>` take the id. A reset builder reading only the stem +has to infer that the leading numeric segment before the first hyphen is the project id — an inference +about a naming convention, made by an agent that has just lost all its context. Stating it costs one line. + +**Changed** — `Project ID:` is now emitted explicitly in the inline frame and `Porch project ID:` in the +long form, and `Project ID:` joined the conditional required markers for porch lanes. + +### On both tests "locking in the weaker behaviour" + +Codex is right about this and it is the second time in this phase. The tests asserted `Project:` and +`You are a Builder` — exactly what the code produced — so they would have passed indefinitely with both +gaps present. This is the same failure as the earlier marker-list problem: **tests written from the +implementation confirm the implementation.** The corrective is to write frame assertions from the spec's +wording, which is what the new tests do (`Project ID: 1273`, `.builder-role.md`, `Porch project ID: 1273`). + +Logged for the review's lessons — it has now caused three separate defects in this project. + +--- + +## Gemini — APPROVE + +No issues raised. + +## Claude — APPROVE + +No issues raised; noted the deviation documented in the iteration-1 rebuttal (verbatim resume notice in +the long form rather than inline) as transparently recorded. + +--- + +## Net effect + +Two spec-conformance gaps closed in the identity/project frame; two tests added, one strengthened. +Tests 40 → 42. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter4-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter4-rebuttals.md new file mode 100644 index 000000000..6f921c826 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter4-rebuttals.md @@ -0,0 +1,75 @@ +# Rebuttal — Phase 5 (Re-orientation assembly), iteration 4 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +Accepted. This finding is different in kind from the previous three: it is a hole in the **enforcement +mechanism**, not in the frame content. + +--- + +## Codex — REQUEST_CHANGES + +### Issue: "An incomplete porch identity frame still assembles instead of aborting" + +**Accepted.** `assembleReorientation` hard-required only top-level fields — `builderId`, `worktree`, +`branch`, `protocol`, `mode`, `statePath`. If `context.porch` existed but `projectId`, `projectName` or +`phase` was empty, assembly succeeded and emitted: + +``` +- Project ID: +- Project: (phase: ) +``` + +**Why the marker validation could not catch it.** `REQUIRED_INLINE_MARKERS` and +`conditionalInlineMarkers` match on **labels**: `'Project ID:'`, `'Project:'`, `'porch next'`. An empty +`projectId` still renders the literal `Project ID:` and satisfies the check. So the mechanism I built to +make partial frames impossible was structurally blind to a partial frame — it verified that the *slots* +existed, never that anything was in them. + +That is worse than the three earlier phase-5 findings, which were missing content. This one is the +guarantee itself being weaker than advertised: a reset would have cleared a live builder's context and +handed back a frame that passed every completeness check while telling it nothing about its project. +**Presence of a label is not presence of a value.** + +**Changed** — porch identity is validated field by field before assembly, with named errors +(`porch.projectId`, `porch.projectName`, `porch.phase`), alongside the existing top-level requirements. + +**One field deliberately excluded**: `currentPlanPhase`. A porch lane sitting between plan phases +genuinely has none — phase 4 already models it as nullable and reads a literal `null` from `status.yaml` +as absent. Sweeping it into the required set would abort valid resets, which is the opposite failure. A +test pins that a null `currentPlanPhase` still assembles. + +### Issue: "Tests do not cover that gap" + +**Accepted** — the abort-path tests covered missing top-level fields, the missing resume notice and a +spawn-prompt failure, but never an empty porch subfield. + +**Changed** — four tests added: one abort case per porch subfield (`projectId`, `projectName`, `phase`), +each asserting the named error, plus the null-`currentPlanPhase` case above. + +--- + +## Gemini — APPROVE + +No issues raised. + +## Claude — APPROVE + +No issues raised. + +--- + +## Note on this phase's iteration count + +Four rounds, four genuine defects, all found by the same reviewer. The pattern across them is worth +carrying into the review: **every one was a case of my tests validating what the code did rather than what +the spec required** — the marker list that omitted project/issue, the forwarding test that stopped at +spec/plan, the identity assertions that matched the weaker string, and now a completeness check that +matched labels instead of values. Absorbing four rounds here is the right trade against shipping a reset +that clears a live builder's context and returns an empty frame. + +--- + +## Net effect + +The completeness guarantee now checks values, not just slots. Tests 42 → 46. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter6-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter6-rebuttals.md new file mode 100644 index 000000000..f8bb0af2f --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter6-rebuttals.md @@ -0,0 +1,96 @@ +# Rebuttal — Phase 5 (Re-orientation assembly), iteration 6 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +**Accepted in full.** Two APPROVEs would have shipped this. The dissenter is right, and the finding is a +real defect on a live path — not a repeat of the iteration-5 dispute, which was about a field that does +not exist on `TemplateContext` at all. + +--- + +## Codex — REQUEST_CHANGES + +### Issue 1: "`input_description` is reconstructed incorrectly for issue-driven protocols" + +**Accepted.** Verified before acting, field by field: + +1. **`input_description` *is* on `TemplateContext`** — unlike `artifact_name` in iteration 5. This is the + distinction that makes the two findings different in kind, and it is why this one stands. +2. **Every spawn entry point populates it, with four distinct values**: + - `spawn.ts:455` — `the feature specified in ${specRelPath}` (spec-driven) + - `spawn.ts:543` — `an ad-hoc task`, plus `task_text` (`--task`) + - `spawn.ts:607` — `running the ${PROTOCOL} protocol` (protocol-only) + - `spawn.ts:837` — `work for GitHub Issue #${issueNumber}` (issue-driven) +3. **Live templates render it**: `{{input_description}}` is line 3 — the *first content line* — of + `bugfix`, `air`, `aspir` and `spir` `builder-prompt.md`. + +My implementation produced **two** of those four. A BUGFIX or AIR lane has no spec, so it fell through to +the fallback and a reset builder was told it was *"running the BUGFIX protocol"* instead of *"work for +GitHub Issue #1288"*. On exactly the lanes where the issue body **is** the spec, the opening line of the +prompt dropped the one thing identifying what the builder is working on. + +A second, quieter defect in the same expression: my fallback read `the ${PROTOCOL} protocol`, where spawn +writes `running the ${PROTOCOL} protocol`. Even the branch I did implement was not spawn-equivalent. + +**Changed** — `buildInputDescription()` now mirrors all four spawn entry points, with each branch +annotated by the `spawn.ts` line it reproduces. + +**One thing Codex did not flag, found while fixing it.** The obvious branch order is wrong. A `--task` +builder gets a porch project keyed on its *builder id*, and `context.ts` falls back to +`issueNumber: issueNumber ?? porch?.projectId` — so `issueNumber` is populated for task builders too. +Testing `issueNumber` before `taskText` would route every task builder down the issue-driven branch and +announce a GitHub issue that does not exist. The order is spec → task → issue → protocol-only, and it is +documented as load-bearing at the function so a later reader does not "tidy" it. Reconstructing the task +lane at all required carrying `taskText` (already persisted as `builders.task_text`) through +`ResolvedBuilderContext`. + +### Issue 2: "The tests do not cover that drift" + +**Accepted — and this is the fourth time in this phase that my tests validated what the code did rather +than what the spec required.** The existing forwarding test asserted `protocol`, `mode`, `spec`, `plan` +and `issue`, and stopped exactly where the bug was. + +**Changed** — five tests, one per spawn entry point plus a negative: + +- spec-driven → the literal `spawn.ts:455` string +- issue-driven → `work for GitHub Issue #1288` on a spec-less BUGFIX lane (this is the regression test + for the reported bug; it fails against the previous implementation) +- ad-hoc task → `an ad-hoc task` **and** `task_text` forwarded, on a context that also carries an + `issueNumber`, so the branch-order trap is pinned rather than merely commented +- protocol-only → `running the ASPIR protocol`, pinning the dropped `running the` +- `task_text` absent on every non-task lane + +They assert spawn's **literal strings**, so if spawn's wording changes the tests fail here instead of the +two surfaces drifting apart silently. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. Recorded plainly: both reviewed the same code and missed a defect on the first content +line of four protocols' builder prompts. **A majority APPROVE is not consensus** — the same lesson phase 2 +produced, now with the majority on the wrong side twice in one project. + +--- + +## Note on the iteration count + +Six rounds. Every defect found by the same reviewer, and every one an instance of a single pattern: +**hand-rolled reconstruction of a spawn structure drifting from what spawn actually emits.** The marker +list that omitted project/issue, the restated resume notice that dropped its `porch init` fallback, the +identity assertions that matched the weaker string, the completeness check that matched labels instead of +values, and now the input framing that covered half the entry points. + +The structural answer landed in iteration 5 — typing the port against the canonical `TemplateContext` — +and it is worth being honest that **it would not have caught this one**. `input_description` was always +present and always type-correct; it was merely *wrong*. Types close the "field went missing" class, not +the "field carries the wrong value" class, and the second class needs tests pinned to the literal strings +spawn produces. That is now the case for this field. + +--- + +## Net effect + +Issue-driven and ad-hoc-task lanes now receive the same opening framing a fresh spawn delivers; the +protocol-only lane matches spawn's wording exactly. `taskText` threaded through the resolved context. +Tests 46 → 51. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter1-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter1-rebuttals.md new file mode 100644 index 000000000..9e1431b3f --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter1-rebuttals.md @@ -0,0 +1,99 @@ +# Rebuttal — Phase 6 (Reset orchestrator + CLI wiring), iteration 1 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude COMMENT · Codex REQUEST_CHANGES (HIGH) + +**All three findings accepted.** Nothing disputed. Two of them are wrapper bugs that the orchestrator's +own tests structurally could not see, which is the more useful lesson than either bug. + +--- + +## Codex — REQUEST_CHANGES + +### Issue 1: "`afx reset` does not reuse `afx send`-style target resolution despite claiming it does" + +**Accepted, and the docstring made it worse.** I wrote in `reset.ts`'s header comment that "addressing, +workspace detection and sender identity are reused verbatim from `afx send` — there is exactly one address +resolver", and then used `getBuilder(target, workspace)`, which matches the id **exactly** +(`state.ts:256-266`). + +What I actually reused was workspace detection and sender identity. Not addressing. The comment asserted a +property the code did not have, which is worse than the gap: a later reader checking "is addressing +consistent?" would have read the comment and moved on. + +The user-visible consequence is sharp. `afx send 1273` reaches a builder registered as `aspir-1273`; +`afx reset 1273` would have failed with "no builder". A command the architect cannot address the way they +already type addresses is a command that gets typed wrong — and this one gets typed in exactly the +situation where care is scarcest, with a builder wedged and context running out. + +**Changed** — `findBuilderById` (`lib/builder-lookup.ts:48`), the resolver `afx dev`, `afx attach` and +`afx setup` already share. It tries the exact lookup, then `resolveAgentName` against local builders, then +Tower's terminal registry, and reports **AMBIGUOUS with the candidate list** rather than silently picking +one. The docstring now says what the code does. + +### Issue 2: "`--dry-run` prints a blank line under the save-request header" + +**Accepted. This one is embarrassing** — `console.log(result.payload?.longForm ? '' : '')` prints the +empty string on both branches. It is a placeholder I left in and never came back to, and it defeated the +single most useful thing a dry run shows: the exact instruction the builder will be asked to comply with, +which is what the entire R2 gate then verifies compliance *with*. + +The root cause was structural, not a typo — `runReset` never exposed `saveRequest`, so the command had +nothing to print even had I written the line correctly. + +**Changed** — `saveRequest` is now on `ResetResult` and printed under its header. Two tests: one that the +dry run exposes a request carrying the state path and **the nonce**, and one that the request a live run +actually sends is byte-identical to the one the dry run advertised (nonce factored out). A preview that +does not match the real thing is not a preview. + +### Issue 3: "No command-surface test catches either issue" + +**Accepted, and this is the real finding.** Both bugs lived in the wrapper, and the orchestrator's 27 +tests could not see either by construction: the state machine cannot tell whether `sendRaw` was bound to +Tower's `raw` route or its `escape` route, nor whether the builder was found by exact id or by the shared +resolver. I had proven the dangerous part thoroughly and left the boring part — the wiring — unproven, +which is precisely where both bugs were. It is also where the near-miss `/clear`-via-escape bug was. + +**Changed** — new `spec-1273-reset-command.test.ts`, 11 tests over the wrapper: + +- target resolved via the shared resolver; unresolvable/ambiguous aborts before `runReset` is called +- an incomplete registry row (missing worktree/branch) refuses +- **`/clear` goes down `raw`, ESC goes down `escape`, and neither carries the other's flag** — the + assertion that would have caught the near-miss +- the save request and re-orientation travel as formatted messages (neither flag set) +- `lastDataAt` is forwarded as `undefined`, not collapsed to `0`, at the port boundary +- a non-running terminal reports absent +- `--timeout` converts seconds → ms (an unconverted `300` would wait 0.3s and abort against every real + builder) +- `--note`, `--dry-run`, `--interrupt-first` plumbing +- an aborted run sets a non-zero exit code + +--- + +## Claude — COMMENT + +Raised the `--dry-run` display bug independently; fixed above. No other issues. + +## Gemini — APPROVE + +No issues raised. + +--- + +## Note on what these three phases have in common + +Phases 5 and 6 have now produced the same lesson three times, from three directions: **the code I verified +hardest was correct, and the defects were all in what I assumed rather than checked.** The re-orientation +drifted from what spawn emits; `/clear` was bound to a route that discards its argument; the target +resolver was asserted-by-comment rather than reused. In every case the fix was to go read the thing I had +described from memory. + +Worth carrying into the review as a lesson candidate: *a docstring claiming an invariant is not evidence +of it — when a comment says "this reuses X", the reviewer's job (and mine) is to open X.* + +--- + +## Net effect + +Addressing parity with the rest of `afx`; `--dry-run` prints all three of the outputs it promises. Tests +3926 → 3939: +2 in the orchestrator file (27 → 29, the dry-run/save-request pair) and +11 command-surface +tests. Build clean. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter2-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter2-rebuttals.md new file mode 100644 index 000000000..9bd8abb41 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter2-rebuttals.md @@ -0,0 +1,76 @@ +# Rebuttal — Phase 6 (Reset orchestrator + CLI wiring), iteration 2 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +**Accepted in full.** This is the third time in this project that two APPROVEs would have shipped a real +defect, and the second time the defect was safety-critical. + +--- + +## Codex — REQUEST_CHANGES + +### Issue 1: "The CLI accepts numeric flag values that bypass or break core safety gates" + +**Accepted.** Verified each claim against the code before fixing: + +- **`--quiet-window -1` → R4 disabled.** The quiescence check is + `clock.now() - observation.lastDataAt >= quietWindowMs`. With a negative window that comparison is true + on the first poll regardless of what the terminal is doing, so a builder mid-turn passes the gate + immediately and gets cleared. The whole point of phase 2's `lastDataAt` work, defeated by a flag. +- **`--min-bytes -1` → R2's substance floor disabled.** `bytes < minBytes` is never true, so a + three-line stub — or an empty file carrying only the nonce — is accepted as a working-state save. +- **`--timeout nope` → `NaN`.** Worse than a wrong timeout: every comparison against `NaN` is false, so + `clock.now() >= deadline` never fires and the receipt wait **never terminates**. The command hangs + rather than aborting. + +The common shape is what makes this worse than an input-validation nit: none of these produce an error or +a degraded run. Each one switches off a specific protection **while the run still reports success**. A +reset that clears a busy builder and prints a clean step log is precisely the outcome the step-log design +exists to make impossible — and it was reachable by typing a number. + +**Changed, in two places deliberately.** + +1. **CLI boundary** (`cli.ts`): `--timeout`, `--min-bytes`, `--quiet-window` each go through a + positive-integer check that errors and exits 1. Verified against the real binary, not just the build: + `--quiet-window=-1`, `--timeout=nope` and `--min-bytes=0` all print a named error and exit 1. +2. **The orchestrator itself** (`runReset`): every timing/threshold parameter is validated in a new step 0, + before preflight. This is not redundant. The orchestrator is the component that *owns* R2 and R4, and + it should not delegate its own preconditions to whoever calls it — a programmatic caller must not be + able to disable an invariant by passing a number. The guard uses `Number.isFinite`, not a bare `> 0`: + `NaN > 0` is false but so is `NaN <= 0`, so a NaN slips past any single comparison written the obvious + way. Infinity is rejected too, since an infinite deadline is a hang. + +### Issue 2: "The command-surface tests do not cover invalid numeric flag input" + +**Accepted.** Same structural gap as iteration 1's — the tests covered the values a well-behaved caller +passes, not the ones that break the gates. + +**Changed** — 7 orchestrator tests and 2 wrapper tests: + +- one per gate-disabling value (negative and zero quiet window, negative `minBytes`, `NaN` and `Infinity` + timeouts, zero quiesce timeout), each asserting the abort happens **before** any message, raw write or + file write — the same "nothing was touched" assertion the R1 tests use +- one test that *demonstrates* the hazard rather than only the guard: a permanently-busy terminal with + `quietWindowMs: -1`, which without the guard would sail through quiescence +- wrapper: a rejected value surfaces as a failure rather than being swallowed into a success report, and + an unset flag is forwarded as `undefined` so the orchestrator's own default applies (passing `0` for an + absent flag would disable the very gate the default enforces) + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. + +Recorded plainly, because the pattern is now the most useful thing this project has produced: **three +times in two phases, the majority approved code with a real defect in it.** Iteration 5 (`input_description` +covering half of spawn's entry points), iteration 1 of this phase (addressing parity + a blank `--dry-run` +output), and now flags that silently disable R2 and R4. Each time the single dissenter was right on the +facts. + +--- + +## Net effect + +Safety-critical flags can no longer be used to switch off the invariants they tune, at either the CLI or +the library boundary. Tests 3939 → 3948. Build clean; validation verified against the real `afx` binary. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter3-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter3-rebuttals.md new file mode 100644 index 000000000..71f6ffcd2 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter3-rebuttals.md @@ -0,0 +1,63 @@ +# Rebuttal — Phase 6 (Reset orchestrator + CLI wiring), iteration 3 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +**Accepted.** Fourth round in this project where two APPROVEs would have shipped a defect, and the third +consecutive one in this phase where the finding is about the WRAPPER rather than the state machine. + +--- + +## Codex — REQUEST_CHANGES + +### Issue: "The real wrapper never binds terminal-output reads, so `/clear` confirmation cannot work outside tests" + +**Accepted.** `readRecentOutput` is optional on `TerminalPort`, and `confirmClear` opens with +`if (!terminal.readRecentOutput) return false;`. My production `buildTerminalPort` never set it. So on +every real run `confirmClear` returned false without doing anything, and the report printed +`clear-unconfirmed`. + +That is worse than a missing feature, and it is the same failure shape as the `/clear`-via-escape +near-miss earlier in this phase: **a step that appears in the report as though it were attempted, and +could only ever pass in tests.** An architect reading `clear-unconfirmed` would reasonably infer "we +looked and could not confirm", when the truth was "we never looked". My own orchestrator tests covered +the confirmed branch using a mock that supplied the method the real code never bound — the tests were +green *because* they were testing something production did not do. + +**Root cause, checked rather than assumed.** I had believed there was no Tower API for terminal output. +There is: `GET /api/terminals/:id/output` (`tower-routes.ts:936`), backed by +`PtyManager.getOutput(id, lines, offset)` returning `{ lines, total, hasMore }`. It has existed since the +terminal manager was written. What was missing was a **client binding** — `TowerClient` had no method for +that route, so from where I was standing the capability was invisible. That is exactly the assumption +this project keeps punishing: I described a capability from memory instead of opening the file. + +**Changed**: + +- `packages/core/src/tower-client.ts` — new `getTerminalOutput(terminalId, lines = 100)`, following the + existing `getTerminal`/`listTerminals` shape. +- `commands/reset.ts` — `readRecentOutput` bound for real, reading the last 50 lines and joining them. +- Confirmation stays **advisory**: a null result (older Tower, 404, terminal gone) degrades to + "unconfirmed" and never fails a reset that already succeeded. That property is now tested rather than + asserted in a comment. + +Two wrapper tests: the method is bound and returns the terminal's recent output, and a Tower that cannot +serve it yields `null` instead of throwing. + +**A note on why this was not caught by "the tests pass".** The orchestrator test for the confirmed branch +constructs a terminal with `recentOutput` supplied. It proves `confirmClear` reads correctly *given* the +port. It cannot prove the port exists in production — that is a wrapper property, and it is the third such +property this phase (channel binding, target resolution, now output reads) that the state-machine tests +were structurally blind to. The command-surface file introduced in iteration 1 is where these belong, and +it is now carrying its third regression. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. + +--- + +## Net effect + +`/clear` confirmation now works outside tests. A previously unbound Tower route gained its client method. +Tests 3948 → 3950. Build clean. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter4-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter4-rebuttals.md new file mode 100644 index 000000000..376c5e0bb --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter4-rebuttals.md @@ -0,0 +1,55 @@ +# Rebuttal — Phase 6 (Reset orchestrator + CLI wiring), iteration 4 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +**Accepted.** + +--- + +## Codex — REQUEST_CHANGES + +### Issue: "Missing the phase's promised wedged-builder integration coverage for `--interrupt-first`" + +**Accepted, and this is a plan commitment I skipped rather than an oversight.** The plan's Test Plan for +this phase says: + +> **Integration**: spec scenario 14a — wedged builder → `--interrupt-first` → turn breaks → save request +> read → flow completes. Uses the existing terminal test harness; **skipped-with-annotation only if the +> harness cannot simulate a wedged turn, and called out in the review if so.** + +The plan gave me an explicit escape hatch and required me to *declare* it. I did neither — I did not write +the test and did not annotate a skip. Silently dropping the one scenario that models the original incident +is the worst of the three options available. + +**It turns out the escape hatch was not needed.** The wedge is observable to reset in exactly two ways: the +builder does not act on messages it has received, and its terminal keeps emitting. Both are expressible +against the injected ports, and the ESC is what flips them. So no harness limitation applied — I had +assumed one without checking, which is the same mistake this phase has now produced four times. + +**Changed** — scenario 14a as two tests: + +1. **The recovery.** A builder that receives the save request but never reads it, with a terminal that + never falls silent. With `--interrupt-first`, the ESC precedes the request, the turn ends, the queued + request processes, the state file appears, quiescence is reached and the flow completes through + `/clear`. +2. **The control, which is the more important half.** The *same* wedged builder reset *without* the flag: + the request is never read, the receipt never verifies, and nothing is cleared. Without this, test 1 + proves only that a permissive harness lets the flow through; with it, the flag is demonstrably what + made the difference. + +The control also asserts the abort message names `--interrupt-first`, so an architect who hits the wedge +without the flag learns the recovery exists at the moment they need it — the incident's actual failure was +that this recipe lived only in architect lore. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. + +--- + +## Net effect + +The scenario that motivated the feature is now covered, with a control proving the mechanism rather than +the harness. Tests 3950 → 3952. Build clean. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter5-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter5-rebuttals.md new file mode 100644 index 000000000..f33d8e0b5 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter5-rebuttals.md @@ -0,0 +1,96 @@ +# Rebuttal — Phase 6 (Reset orchestrator + CLI wiring), iteration 5 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +**Issue 2 accepted in full. Issue 1 accepted in substance — the plan's escape hatch genuinely applies, and +I am now exercising it explicitly instead of ignoring the requirement, which is what I did last round.** + +--- + +## Codex — Issue 2: "Reset does not actually preflight terminal writability" + +**Accepted. This is a real gap against a stated acceptance criterion**, and Codex quoted the right one: +*"Unsupported harness and non-writable terminal abort loudly with no terminal writes."* I implemented the +harness half and not the writability half. + +The consequence is a violation of the phase's validate-before-touch contract. An unwritable terminal +passed preflight, `.builder-reorient.md` was written into the builder's worktree, the save request was +sent into a void, and only then did the run fail — having already touched the worktree for a reset that +could never proceed. + +**Root cause was the same assumption pattern as iteration 3.** I checked `status === 'running'` and treated +it as evidence of writability. It is not, and the codebase already knew that: `PtySession.writable` +(`pty-session.ts:396`) exists precisely because *"a session whose shellper connection died reports status +'running' until teardown, and writes to it are dropped (#1198)"*. The getter had been there since #1198 — +but it was **never serialised into `info`**, so no client could see it. Exactly the shape of iteration 3's +finding: the capability existed, the binding did not, and I concluded from its absence that the capability +was absent. + +**Changed**, mirroring what phase 2 did for `lastDataAt`: + +- `PtySessionInfo` gains `writable`, and `get info()` serialises it. +- `TowerTerminal` (core) gains the optional field. +- The reset terminal port forwards it **as-is, including `undefined`**. +- `runReset` preflight refuses on `writable === false`, before any write. + +**One deliberate asymmetry, stated because it looks inconsistent otherwise.** An unreported `lastDataAt` +*refuses*; an unreported `writable` *proceeds*. The rule is not "always refuse on unknown" — it is +**refuse what fails silently**. An unobservable turn state fails silently and destructively (clear a +builder mid-turn, no signal). An unobservable write path fails loudly and harmlessly (the first send +throws). Blocking older Towers from resetting at all would be a cost with no safety return. + +Tests: preflight aborts with zero file writes and zero terminal writes; an unreported `writable` still +completes; and at the PTY level — `writable` is serialised in `info`, agrees with the getter, and reports +`false` while `status` still says `'running'`, which is the #1198 disagreement reproduced without +contrivance. + +--- + +## Codex — Issue 1: "Scenario 14a is still a unit-level simulation, not an integration test" + +**The factual claim is correct, and I am invoking the plan's escape hatch rather than disputing it — but +this time explicitly, which is the part I got wrong last round.** + +The plan reads: + +> Uses the existing terminal test harness; **skipped-with-annotation only if the harness cannot simulate a +> wedged turn, and called out in the review if so.** + +**I checked what the harness actually is before claiming a limitation** (having been burned twice this +phase for not doing so). `packages/codev/src/agent-farm/__tests__/pty-last-data-at.test.ts` and its +siblings **mock `node-pty`** — they construct a `PtySession` against a stub with no real process. There is +no harness in this repo that runs a *Claude agent* in a PTY. + +That matters because of what a wedge actually is. It is not a property of a terminal; it is a property of +an **agent's turn**: the builder has received the save request and will not act on it until its current +turn ends. A PTY harness has no agent, no turn, and no message queue — so there is nothing there to wedge. +Writing a "PtySession integration test for scenario 14a" would produce a test that spawns a stub, writes +ESC to it, and asserts the byte arrived. That proves ESC delivery (already covered by phase 1) and asserts +nothing whatever about wedge recovery, while *looking* like it did. A test that appears to cover the +headline scenario and does not is worse than a declared gap — that is the same "looks attempted, can only +pass in tests" failure mode as iterations 3 and 5. + +So, **declared plainly, as the plan requires**: + +> **Scenario 14a is covered at the port level, not the PTY level.** The existing terminal harness mocks +> `node-pty` and cannot model an agent's turn semantics, which is the thing a wedge consists of. The two +> port-level tests model the wedge where it is observable to reset — the builder does not act on received +> messages and its terminal keeps emitting — with ESC as the trigger that flips both, plus a control +> proving the flag rather than the harness is what makes the difference. **True end-to-end coverage of +> this scenario requires the live run against a real wedged builder, which is exactly the manual step +> still outstanding and now scheduled for the post-merge verify window.** + +I would rather carry that as a named gap in the review than manufacture a test that launders it. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. + +--- + +## Net effect + +Writability is now observable end-to-end (session → Tower → client → preflight) and refuses before any +write. Scenario 14a's coverage level is declared rather than implied. Tests 3952 → 3957. Build clean. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter6-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter6-rebuttals.md new file mode 100644 index 000000000..89192a3d1 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter6-rebuttals.md @@ -0,0 +1,54 @@ +# Rebuttal — Phase 6 (Reset orchestrator + CLI wiring), iteration 6 + +**Verdicts**: Gemini APPROVE (HIGH) · Claude APPROVE (HIGH) · Codex REQUEST_CHANGES (HIGH) + +**Accepted.** + +--- + +## Codex — REQUEST_CHANGES + +### Issue: "Quiescence misreports a vanished terminal as an old-Tower `lastDataAt` problem" + +**Accepted.** `awaitQuiescence` tested `observation.lastDataAt === undefined` without first testing +`observation.exists`. A terminal that disappears mid-run — the builder exited, Tower restarted, the +session was killed — also reports no `lastDataAt`, so it fell into the `unobservable` branch and produced: + +> *"this Tower does not report 'lastDataAt' (Spec 1273 / phase 2) … Restart Tower on a current build."* + +Which is a confidently wrong diagnosis. The architect goes to check a version number while the actual +event is that their builder's terminal died. + +**This is a diagnosis bug, not a safety bug, and it is worth being precise about which.** The invariants +held: both paths abort, and neither clears. What failed is the thing the report exists for. The whole +argument for the step log and the evidence-carrying abort messages is that *"the safe outcome" is not +sufficient — the operator has to know what happened*. An abort that names the wrong cause is the report +failing at its one job, and it is the same family as the earlier `clear-unconfirmed` finding: output that +looks like knowledge and is not. + +**Changed** — `exists` is now checked first, in both the poll loop and the final post-deadline check, with +its own `terminal-gone` reason and its own message: + +> *"Builder 'X' lost its terminal while waiting for its turn to end. Nothing was cleared. Its saved state +> is at `<path>` — that file survives the terminal, so respawn with `afx spawn <id> --resume` and point +> the new session at it."* + +The recovery is the point. The state file was already verified before quiescence began, so a vanished +terminal is the one abort where the architect has a *complete, nonce-verified save* and no session to +apply it to — precisely when telling them where it is has the most value. + +Test asserts the vanished case reports "lost its terminal", **does not** mention `lastDataAt`, and names +the state file. The negative assertion is the load-bearing one: without it, a future refactor could fold +the branches back together and the test would still pass. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. + +--- + +## Net effect + +Two indistinguishable failures now produce two accurate diagnoses. Tests 3957 → 3958. Build clean. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-review-iter1-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-review-iter1-rebuttals.md new file mode 100644 index 000000000..a465e9836 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-review-iter1-rebuttals.md @@ -0,0 +1,68 @@ +# Rebuttal — PR review, iteration 1 + +**Verdicts**: Gemini APPROVE · Claude APPROVE · Codex REQUEST_CHANGES + +**Issue 1 accepted and fixed. Issue 2 disputed with evidence. Issue 3 is an environment note, not a finding.** + +--- + +## Codex — Issue 1: "The branch is not up to date with `main`" + +**Accepted and fixed.** `git rev-list --left-right --count main...HEAD` reported `22 78`; by the time I +acted, main had moved to 29 ahead. Merged `origin/main` into the branch (merge, not rebase — rebasing +would rewrite 50 porch-authored state commits, and this repo's standing rule is "do not squash; individual +commits document the process"). + +Verified after the merge rather than assuming it was clean: + +- Merge had no conflicts. +- Full rebuild of both packages: clean. +- **4014 tests passing**, 48 pre-existing skips (up from 3976 — main brought ~38 new tests of its own, + including #1288's consult default-model work and #1279's template-delivery guards). +- `git rev-list --left-right --count origin/main...HEAD` now reports `0 79`. + +Worth noting what arrived in the merge, since it touches this project's area: #1288 changed the default +consult lane models. Nothing in this branch pins model ids, so there is no interaction. + +## Codex — Issue 2: "Commit history is not consistently in the required `[Spec 1273][Phase]` format" + +**Disputed. The commits in question are not mine — porch writes them.** + +Checked before responding: + +- Non-merge commits on this branch that are **mine**: 28. Of those, the number failing the + `[Spec 1273]` / `[#1273]` convention is **0**. +- Non-merge commits that are `chore(porch): …`: **50**. These are generated by porch's own state machine + via `writeStateAndCommit` — e.g. `packages/codev/src/commands/porch/next.ts:315,349,375`. They record + phase transitions, build-complete signals and gate state. A builder does not author them and cannot + choose their format. +- They are also the established norm, not a defect introduced here: **188 `chore(porch):` commits already + exist on `main`** in the last 400 commits. + +Rewriting them would mean rewriting porch's state history to make it look like builder work, which is both +inaccurate and outside a builder's remit. If the convention should change, that is a change to porch's +commit-message format — a separate issue against `area/porch`, not something to fix in this PR. + +I am flagging rather than silently ignoring this: if the architect wants porch's format changed, I am +happy to file it. + +## Codex — Issue 3: "Could not independently rerun Vitest (read-only filesystem, EPERM)" + +**Not a finding, and correctly labelled as such by Codex.** Recorded for the architect's benefit: the +suite was run by me in the worktree and by porch's own `tests` check, both green post-merge. Codex verified +by code and test inspection instead. No action. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. Claude explicitly noted the Known Gaps section as honest rather than as an omission, +which is the reading I intended — the un-run live e2e is declared, not hidden. + +--- + +## Net effect + +Branch is current with `main` (`0 79`), 4014 tests passing, build clean. Commit-format finding disputed +with evidence: 0/28 builder commits violate the convention; the 50 flagged commits are porch's own and +match 188 existing ones on `main`. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-review-iter2-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-review-iter2-rebuttals.md new file mode 100644 index 000000000..df59bfc8a --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-review-iter2-rebuttals.md @@ -0,0 +1,50 @@ +# Rebuttal — PR review, iteration 2 + +**Verdicts**: Gemini APPROVE · Claude APPROVE · Codex REQUEST_CHANGES + +**Accepted.** A genuinely new finding, not a repeat — and the fifth time in this project the majority +approved something real. + +--- + +## Codex — "The clear-confirmation check can report success on a plain `/clear` echo" + +**Accepted.** The pattern was +`/context (?:cleared|reset)|conversation cleared|\/clear/i`, and the final alternative matches the **echo +of the very keystroke we just typed**. A PTY echoes its input, so `/clear` appears in recent output on +*every* run. The check was self-fulfilling: it reported `clear-confirmed` whether or not anything was +actually cleared. + +**This is the same defect as iteration 3 of phase 6, wearing the opposite mask.** There, `readRecentOutput` +was unbound, so the report always said *unconfirmed* — a check that never looked. Here it always said +*confirmed* — a check that looked only at its own reflection. I fixed the first by binding the reader and +introduced the second in the same breath, because I never asked what the output would contain *given that +we just typed into it*. + +**And this direction is strictly worse.** A false "unconfirmed" is conservative; the architect +investigates and finds nothing wrong. A false "confirmed" is trusted — it is the one line of the report +that would tell someone the destructive step actually took effect, and it would have said yes +unconditionally. On a harness where `/clear` silently no-ops, reset would have reported complete success +while the builder kept its entire context. + +**Changed** — the pattern now matches only what the *harness* emits after clearing +(`context cleared/reset`, `conversation cleared/reset`, `cleared conversation`) and never the input echo. +Test: recent output of `> /clear\n> ` — exactly what a PTY echo looks like — must produce +`clear-unconfirmed`, with an explicit negative assertion that `clear-confirmed` is absent. + +Confirmation remains **advisory** by design: it is report-only, and the re-orientation is correct either +way. The point of fixing it is that the report has to be *believable*, which is the same argument that +motivated the step log in the first place. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. + +--- + +## Net effect + +The one line of the report that speaks to whether the destructive step landed no longer answers "yes" +unconditionally. Suite 4014 → 4015, build clean, branch current with `main`. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-review-iter3-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-review-iter3-rebuttals.md new file mode 100644 index 000000000..413a0d409 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-review-iter3-rebuttals.md @@ -0,0 +1,63 @@ +# Rebuttal — PR review, iteration 3 + +**Verdicts**: Gemini APPROVE · Claude APPROVE · Codex REQUEST_CHANGES + +**Accepted.** Third consecutive finding on the same three lines of advisory code, and the third distinct +false-report mechanism. That pattern is itself the lesson. + +--- + +## Codex — "Clear confirmation can still report a false success by matching the echoed save request" + +**Accepted.** Verified: `buildSaveRequest` opens with **`CONTEXT RESET INCOMING — save your working state +now.`**, which matches `/context (?:cleared|reset)/i`. Reset sends that request into the same terminal +moments before clearing, so it is in the buffer on **every run**. Last round I removed the `/clear` +alternative and did not ask the obvious follow-up: *what else in this buffer did I put there?* + +**The three failures in sequence, because the progression is the point:** + +1. `readRecentOutput` unbound → always **unconfirmed**. A check that never looked. +2. Pattern matched `/clear` → always **confirmed**. A check that looked at its own keystroke echo. +3. Pattern matched the save request's header → always **confirmed**. A check that looked at its own + *message*. + +Each fix was a better regex, and each time the regex was the wrong layer. The real defect was scanning a +buffer that **contains reset's own writes** and hoping the pattern would not collide. With reset writing +into the terminal three times per run, collision is not a risk to mitigate — it is the default. + +**Changed structurally, not with a fourth regex.** `readRecentOutput` becomes +`readOutput(): { lines, total }`. The orchestrator snapshots `total` immediately before sending `/clear` +and confirms against **only the lines produced after that point**. Everything reset wrote sits at or below +the snapshot and is excluded *by construction*. A future pattern change cannot reintroduce this class. + +The test harness now models it honestly: `PRE_CLEAR_BUFFER` contains the real save-request header, so if +the windowing regresses, reset's own words leak into the check and the negative tests fail. Two tests pin +the closed cases (echoed `/clear`, echoed save request) and one confirms the check still fires on genuine +post-clear harness output — a guard that only ever returns false is no better than the unbound version. + +**Carried to the review as a named gap, because it is the honest limit here:** the *pattern* remains +unvalidated. I have never observed what a real Claude Code `/clear` emits, so the strings it matches are +still an educated guess. The window fix closes the false-**positive** class; it cannot prove the pattern +ever matches in production. `clear-confirmed` should be read as *"the harness said something clear-like"*, +never as proof. The live e2e settles it, and the pattern may need adjusting afterwards. This is exactly +why confirmation is advisory and report-only — the re-orientation is correct either way, and no invariant +depends on it. + +**On whether to keep this at all:** three rounds of defects in a non-load-bearing nicety is a fair +argument for deleting it. I kept it because the failure it reports on — a `/clear` that silently no-ops — +is the one failure mode the rest of the design cannot detect, and a windowed check is now structurally +sound rather than a guess stacked on a guess. If the architect would rather ship without it, removing +`confirmClear` and its two step names is a clean five-line deletion. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. + +--- + +## Net effect + +Confirmation reads only post-clear output; reset's own text can no longer confirm reset. Suite +4015 → 4016, build clean, branch current with `main`. diff --git a/codev/projects/1273-builder-context-reset-should-b/1273-review-iter4-rebuttals.md b/codev/projects/1273-builder-context-reset-should-b/1273-review-iter4-rebuttals.md new file mode 100644 index 000000000..c12343d58 --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/1273-review-iter4-rebuttals.md @@ -0,0 +1,90 @@ +# Rebuttal — PR review, iteration 4 + +**Verdicts**: Gemini APPROVE · Claude APPROVE · Codex REQUEST_CHANGES + +**Both findings disputed with direct evidence.** This is the first round where I am rejecting Codex +outright, and I want to be explicit that it is a change in kind, not fatigue: the previous six findings +were real and I took every one. These two contradict documented, enforced conventions of this repo. + +--- + +## Disputed 1: "`codev/state/aspir-1273_thread.md` is builder/runtime state and should be dropped" + +**Rejected. Committing the thread is the documented, mandatory behaviour — not an accident.** + +The builder role document (the one governing me, quoted verbatim): + +> **Commit/retention rule**: **the default disposition is COMMIT.** Stage and commit your thread file as +> part of your PR. […] Silently leaving the thread uncommitted by accident is a bug, not an exercise of +> the exception. The cohort's situational-awareness goal depends on threads surviving to `main`. + +And the convention is not aspirational — `git ls-tree origin/main codev/state/` returns **141 thread +files** already on `main`. Two more (`bugfix-1279_thread.md`, `spir-1252_thread.md`) arrived in the merge I +did two rounds ago. + +The design intent is stated in the role doc and in `CLAUDE.md`'s inter-agent messaging section: in-flight, +architects and sibling builders read `.builders/<id>/codev/state/<id>_thread.md`; **post-merge the thread +lands in `codev/state/` on `main` alongside `codev/reviews/` and becomes part of the historical review +record.** Dropping it would remove this project's narrative — including the analysis of the near-miss bugs +that the reviewers themselves have been surfacing — from the record. + +If the convention should change, that is a repo-wide decision affecting 141 existing files, not something +to enact silently in this PR. + +## Disputed 2: "Commit history is not consistently in the required `[Spec XXXX][Phase]` format" + +**Rejected on the premise: `[Phase: …]` is not required on every commit, and all seven cited commits are +correctly formatted.** + +`CLAUDE.md`, the repo's own convention block: + +``` +[Spec 42] Initial specification draft +[Spec 42][Phase: user-auth] feat: Add password hashing +[Bugfix #42] Fix: URL-encode username before API call +``` + +The **first documented example carries no phase segment.** The phase segment marks implementation-phase +commits; spec, plan and review commits use the plain `[Spec N]` form. Checking each commit Codex cited: + +| Commit | Subject | Verdict | +|---|---|---| +| `de81103a` | `[Spec 1273] Initial specification draft` | Matches CLAUDE.md's example **verbatim** | +| `d0dea115` | `[Spec 1273] Specification with multi-agent review` | Spec phase — correct form | +| `f9ad2580` | `[Spec 1273] Initial implementation plan` | Plan phase — correct form | +| `d89a64ce` | `[Spec 1273] Plan with multi-agent review` | Plan phase — correct form | +| `cece30e6` | `[Spec 1273] Review: lessons learned and architecture updates` | Review phase — correct form | +| `25bb2964` | `[Spec 1273] Review: rename lessons section…` | Review phase — correct form | +| `67f48ca6` | `[Spec 1273] Review: merge main, rebut commit-format finding` | Review phase — correct form | + +All seven carry `[Spec 1273]`. Four of them are **porch-generated** (porch commits the spec and plan +artifacts itself). Every one of my implementation commits carries the full `[Spec 1273][Phase: …]` form. + +This also **re-raises the finding I rebutted in iteration 1** with the same evidence — that the 50 +`chore(porch):` commits are written by porch's own state machine (`porch/next.ts:315,349,375`) and match +**188 such commits already on `main`**. I am restating it rather than assuming the rebuttal was carried +into this round's context. + +--- + +## Codex — third point: could not rerun Vitest (read-only sandbox, EPERM) + +Environment limitation, correctly labelled by Codex as not a finding. For the record: 4016 tests passing, +build clean, verified in the worktree and by porch's own `tests` check. + +--- + +## Gemini — APPROVE · Claude — APPROVE + +No issues raised. + +--- + +## Net effect + +No code change. Two findings rejected against documented convention (141 thread files on `main`; CLAUDE.md's +own first example carries no phase segment). Branch remains current with `main`, 4016 tests passing. + +**Flagged for the architect rather than actioned**: if either convention *should* change — threads not +shipping to `main`, or porch's commit-message format — those are repo-wide changes and I would file them +as separate issues rather than deviate here. diff --git a/codev/projects/1273-builder-context-reset-should-b/status.yaml b/codev/projects/1273-builder-context-reset-should-b/status.yaml new file mode 100644 index 000000000..d4470840d --- /dev/null +++ b/codev/projects/1273-builder-context-reset-should-b/status.yaml @@ -0,0 +1,280 @@ +id: '1273' +title: builder-context-reset-should-b +protocol: aspir +phase: review +plan_phases: + - id: phase_1 + title: afx interrupt + ESC delivery path + status: complete + - id: phase_2 + title: Quiescence observability (lastDataAt) + status: complete + - id: phase_3 + title: Reset receipt gate (nonce, substance, stability) + status: complete + - id: phase_4 + title: Builder context resolution (protocol, mode, harness capability) + status: complete + - id: phase_5 + title: Re-orientation assembly (complete-or-abort) + status: complete + - id: phase_6 + title: Reset orchestrator + CLI wiring + status: complete + - id: phase_7 + title: Wait discipline and command documentation + status: complete +current_plan_phase: null +gates: + pr: + status: approved + requested_at: '2026-07-30T10:59:16.086Z' + approved_at: '2026-07-30T11:20:42.697Z' + verify-approval: + status: pending +iteration: 1 +build_complete: true +history: + - iteration: 1 + plan_phase: phase_2 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_2-iter1-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_2-iter1-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_2-iter1-claude.txt + - iteration: 1 + plan_phase: phase_3 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_3-iter1-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_3-iter1-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_3-iter1-claude.txt + - iteration: 1 + plan_phase: phase_4 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_4-iter1-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_4-iter1-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_4-iter1-claude.txt + - iteration: 1 + plan_phase: phase_5 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter1-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter1-codex.txt + - model: claude + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter1-claude.txt + - iteration: 2 + plan_phase: phase_5 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter2-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter2-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter2-claude.txt + - iteration: 3 + plan_phase: phase_5 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter3-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter3-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter3-claude.txt + - iteration: 4 + plan_phase: phase_5 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter4-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter4-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter4-claude.txt + - iteration: 5 + plan_phase: phase_5 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter5-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter5-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter5-claude.txt + - iteration: 6 + plan_phase: phase_5 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter6-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter6-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_5-iter6-claude.txt + - iteration: 1 + plan_phase: phase_6 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter1-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter1-codex.txt + - model: claude + verdict: COMMENT + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter1-claude.txt + - iteration: 2 + plan_phase: phase_6 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter2-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter2-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter2-claude.txt + - iteration: 3 + plan_phase: phase_6 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter3-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter3-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter3-claude.txt + - iteration: 4 + plan_phase: phase_6 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter4-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter4-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter4-claude.txt + - iteration: 5 + plan_phase: phase_6 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter5-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter5-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter5-claude.txt + - iteration: 6 + plan_phase: phase_6 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter6-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter6-codex.txt + - model: claude + verdict: APPROVE + file: >- + /Users/mwk/Development/cluesmith/codev/.builders/aspir-1273/codev/projects/1273-builder-context-reset-should-b/1273-phase_6-iter6-claude.txt +started_at: '2026-07-28T00:57:25.240Z' +updated_at: '2026-07-30T11:20:42.698Z' +pr_ready_for_human: false diff --git a/codev/resources/commands/agent-farm.md b/codev/resources/commands/agent-farm.md index cf1b26faf..d6f6e82d0 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -550,6 +550,113 @@ afx send 0042 --file src/api.ts "Review this implementation" --- +### afx interrupt + +Interrupt a builder mid-turn by sending an ESC keystroke to its PTY. + +```bash +afx interrupt <builder> [options] +``` + +**Arguments:** +- `builder` - Target builder. Same addressing as `afx send`. + +**Options:** +- `--no-enter` - Send ESC alone, without the trailing Enter + +**Description:** + +This is the only recovery that reaches a builder **mid-turn**. When a builder chains foreground waits +inside a single turn, every `afx send` — including your order to stop — queues unread until that turn +ends. ESC interrupts the running tool and ends the turn, after which the queued messages process. The +trailing Enter (default) is what lets them through. + +Distinct from `afx send --interrupt`, which sends Ctrl+C. + +**Examples:** + +```bash +# Builder is wedged on a foreground wait and not reading messages +afx interrupt 0042 + +# Then the queued instruction lands +afx send 0042 "That producer died — stop waiting and report." +``` + +--- + +### afx reset + +Reset a builder's context: have it save its working state, clear the conversation, then re-orient it. + +```bash +afx reset <builder> [options] +``` + +**Arguments:** +- `builder` - Target builder. Same addressing as `afx send`. + +**Options:** +- `--note <text>` - Extra context appended to the re-orientation +- `--file <path>` - Append file content to the re-orientation (48KB max, read from *your* filesystem) +- `--dry-run` - Print the save request and both re-orientation payloads; write nothing to the builder +- `--interrupt-first` - Send ESC before the save request, for a builder already wedged mid-turn +- `--mode <strict|soft>` - Override the builder mode if it cannot be detected +- `--timeout <seconds>` - How long to wait for the save-state receipt (default 300) +- `--min-bytes <n>` - Minimum state-file size to accept as substantive (default 1000) +- `--quiet-window <ms>` - Terminal silence that counts as turn-ended (default 1500) + +**Description:** + +Long-running builders exhaust their context window. `afx spawn --resume` reattaches the *same* +conversation, so a deep session resumes deep — it does not give the builder a fresh window. `afx reset` +does, without losing what the builder knows. + +The sequence: + +1. Assemble the re-orientation and write it to `.builder-reorient.md` in the worktree. +2. Ask the builder to write its complete working state to `.builder-state.md`, stamped with a one-time + nonce. +3. Wait for that file and **verify** it: correct nonce (not a stale file from an earlier reset), + substantive size, and stable across two observations (not still being written). +4. Wait for the terminal to fall silent, so the clear is not typed mid-turn. If it does not settle, send + **one** ESC and wait again. +5. Send `/clear`. +6. Deliver the re-orientation: role, protocol, mode, project, worktree, branch, the porch re-entry + instruction, and a pointer to the state file. + +**Every gate fails safe.** If the state file never arrives, carries the wrong nonce, is a stub, is still +growing, or the builder will not go quiet — the command **aborts without clearing** and exits non-zero, +naming the gate that failed. A builder whose context was not cleared has lost nothing. + +Both worktree artifacts use the `.builder-` prefix, so `afx cleanup` still classifies the worktree as +clean, and both are untracked so `porch done`'s staged-file sweep cannot pick them up. + +Requires a harness with in-session context reset (Claude Code). Other harnesses abort loudly rather than +substituting a different mechanism — use the boundary-recycle pattern instead (let the builder finish, +then `afx spawn <id> --resume`). + +**Examples:** + +```bash +# See exactly what would be sent, without touching the builder +afx reset 0042 --dry-run + +# Standard reset +afx reset 0042 + +# Add context that post-dates the builder's saved state +afx reset 0042 --note "PR #90 merged while you were mid-phase. Rebase before continuing." + +# The builder is wedged mid-turn and not reading messages +afx reset 0042 --interrupt-first + +# A builder that legitimately needs longer to write its state +afx reset 0042 --timeout 600 +``` + +--- + ### afx open Open file annotation viewer. diff --git a/codev/reviews/1273-builder-context-reset-should-b.md b/codev/reviews/1273-builder-context-reset-should-b.md new file mode 100644 index 000000000..17ad64399 --- /dev/null +++ b/codev/reviews/1273-builder-context-reset-should-b.md @@ -0,0 +1,163 @@ +# Review: Builder context reset as a first-class flow (Spec 1273) + +**Protocol**: ASPIR (strict, porch-driven) · **Issue**: #1273 · **Branch**: `builder/aspir-1273` + +## Summary + +`afx reset <builder>` turns the hand-run context-reset procedure into a supported command: request a +save-state, **verify** it, wait for the builder's turn to end, `/clear`, then re-orient with the same +protocol framing a fresh spawn delivers. `afx interrupt <builder>` wraps the ESC-into-PTY recovery that +previously lived only in architect lore. Builder-facing wait discipline moves into the role document so +the wedge that motivated all of this is less likely to recur. + +The problem being solved: `afx spawn --resume` reattaches the *same* conversation, so a builder that has +exhausted its context resumes exhausted. There was no supported way to give a long-running builder a fresh +window without losing what it knew. + +Seven implementation phases, 3894 → 3976 tests, 48 files. + +## What shipped + +| Phase | Deliverable | +|---|---| +| 1 | `afx interrupt` — ESC delivery via a new Tower `escape` route | +| 2 | `lastDataAt` surfaced on serialised terminal info — makes output quiescence measurable by a client | +| 3 | Receipt gate — nonce, substance floor, size stability | +| 4 | Builder context resolution — protocol, mode, harness capability, artifact paths | +| 5 | Re-orientation assembly — complete-or-abort | +| 6 | The `afx reset` orchestrator + CLI wiring | +| 7 | Wait discipline in both role docs; `afx reset`/`afx interrupt` in both command-doc and both skill trees | + +### The four safety invariants + +- **R1** — never clear without a persisted re-orientation. Assembly and the `.builder-reorient.md` write + precede every destructive step; assembly failure returns with the builder untouched. +- **R2** — never clear without a *verified* receipt. Missing, stale-nonce, undersized and still-growing + state files each abort with no clear. +- **R3** — the re-orientation is complete or it throws. No code path returns a partial frame. +- **R4** — never clear mid-turn. Bounded wait → **exactly one** ESC escalation → bounded wait → abort. + No third attempt, no "clear anyway". + +Every gate fails toward *not clearing*. A refused reset leaves a builder with its context and a saved +state file; a wrong clear is unrecoverable. + +## Architecture Updates + +Facts worth carrying into `codev/resources/arch.md` (cold tier — none of these belong in the capped hot +file): + +- **Tower's `escape` route ignores the message body.** `POST /api/send` with `escape: true` calls + `writeEscapeToSession`, which writes a hardcoded ESC and discards `message` + (`servers/message-write.ts:46`). It is *not* a general raw-write channel — `raw: true` is. Anything that + needs literal text typed into a PTY (`/clear`, slash commands) must use `raw`. +- **`status: 'running'` is not evidence a terminal is writable.** A session whose shellper connection died + reports `running` until teardown while every write is dropped (#1198). `PtySession.writable` is the + honest signal; it is now serialised into `info` and reaches clients via `TowerTerminal.writable`. +- **The builders registry does not carry protocol or mode for spec-type builders.** + `builders.protocol_name` is NULL for every SPIR/ASPIR lane (`spawn.ts` never passes it on that path), + and mode is computed at spawn and discarded — it exists nowhere in the DB. The ground truth is in the + worktree: porch `status.yaml` (protocol, phase), `.builder-prompt.txt` (`## Mode: STRICT`), + `.builder-start.sh` (the real harness launch line). +- **The role frame survives `/clear`.** Roles are injected via `--append-system-prompt`, a process flag. + Only the initial *user* prompt (protocol/spec/porch framing) is lost, which is what re-orientation must + restore — not the role text. +- **`--resume` re-injects nothing** when a resumable session is found; the builder prompt is only built on + the fresh-launch path. "Re-inject context the way `--resume` does" therefore means reusing + `buildPromptFromTemplate` + `buildResumeNotice`, the *fresh-path* machinery. +- **`GET /api/terminals/:id/output` existed with no client binding** until this spec added + `TowerClient.getTerminalOutput`. Worth remembering as a class: a missing binding makes an existing + capability invisible. + +## Lessons Learned Updates + +**A majority APPROVE is not consensus.** Across this project, **four times** two reviewers approved code +that contained a real defect, and each time the single dissenter was correct on the facts. Phase 5's +`input_description` covered half of spawn's entry points; phase 6 shipped addressing that contradicted its +own docstring, a blank `--dry-run` output, CLI flags that silently disabled R2/R4, and a confirmation step +that could only pass in tests. Decide on the argument, not the count — and a reviewer who dissents every +round may be tracking a *defect class* rather than nitpicking. + +**Test the boring wiring, not just the dangerous logic.** The orchestrator — the part that got the careful +design, the step log, the ordering proofs — was essentially right from the first iteration. *Every* phase-6 +defect was in the thin wrapper. A pure state machine over injected ports is structurally blind to how those +ports are bound: it cannot see whether `sendRaw` went to Tower's `raw` or `escape` route, whether the +builder was resolved by exact id or the shared resolver, or whether an optional port method exists in +production at all. The separate command-surface test file added mid-phase caught four regressions. + +**Beware steps that report as attempted but can only succeed in tests.** Two defects had this exact shape: +`/clear` bound to the escape route (reports a clean run; builder keeps its context) and `readRecentOutput` +left unbound (reports `clear-unconfirmed` forever, having never looked). Both had passing tests, because +the mocks supplied what production did not. When a check can only pass under test, it is worse than absent +— it manufactures false confidence. + +**Types close the "field is missing" class, not the "field is wrong" class.** Phase 5's structural fix — +typing the port against the canonical `TemplateContext` — was correct and would not have caught the very +next defect, because `input_description` was always present and always type-correct, merely wrong. Values +that must match another module's literal output need tests pinned to those literals. + +**A docstring asserting an invariant is not evidence of it.** I wrote "addressing is reused verbatim from +`afx send`" and then used an exact-match lookup. That is worse than the gap: a later reader auditing +addressing consistency would have read the comment and moved on. When a comment says "this reuses X", +open X. + +**Distinguish "refuse on unknown" from "refuse what fails silently".** Reset treats an unreported +`lastDataAt` as a refusal but an unreported `writable` as fine. That looks inconsistent and is deliberate: +an unobservable turn state fails silently *and destructively*; an unobservable write path fails loudly and +harmlessly. Conservatism should be spent where failure is quiet. + +**An abort that names the wrong cause is a bug even when the invariant held.** A vanished terminal was +reported as "your Tower is too old" — safe, but it sends the operator to check a version number while +their builder is gone. If the argument for a design is that the operator gets evidence, wrong evidence is +a defect. + +**Process (strict mode):** take the consultation task from `porch next` and run *that*. I ran consults +directly for six rounds; porch never registered them and its counter drifted five iterations behind. The +files were genuine so the history reconstructed cleanly, but porch does not watch what it did not ask for. + +## Deviations from the plan + +- **Phase 5's `buildResumeNotice` reuse moved from `inline` to `longForm`.** The plan said the verbatim + reuse would sit inline. `buildResumeNotice` opens with "This is a **resumed** builder session", which is + false after a reset, and is seven lines in a frame whose paced-write size is a tested constraint. The + long form preserves the property the plan was protecting — one copy, no drift, `porch init` fallback + intact — without telling a freshly-reset builder something untrue about itself. +- **`taskText` was threaded through `ResolvedBuilderContext`** (a phase-4 type) during phase 6's + `input_description` fix, because the ad-hoc-task spawn lane is otherwise indistinguishable from the + issue-driven one. +- **Phase 4 deliberately did *not* add a `mode` column to the builders table.** It would be NULL for every + currently-running builder, so the worktree derivation is required regardless, and the column would + duplicate a fact the worktree already holds. Flagged to the architect as reversible. + +## Known gaps + +- **Scenario 14a is covered at the port level, not the PTY level.** The existing terminal harness mocks + `node-pty` and cannot model an agent's *turn*, which is what a wedge consists of. A PTY-level test would + assert ESC delivery (phase 1's territory) while appearing to cover wedge recovery. The two port-level + tests model the wedge where it is observable to reset, with a control proving the flag rather than the + harness makes the difference. Declared under the plan's explicit escape hatch. +- **The headline path has not been run end-to-end against a live builder.** `afx reset` needs + `pnpm -w run local-install`, which restarts Tower and affects every builder in the workspace. Per the + architect, this happens in the **post-merge verify window**, with the restart timing possibly waiting on + the shannon workspace stabilising. **3976 passing tests is not "it works"**, and nothing in this review + should be read as claiming otherwise. +- **The clear-confirmation *pattern* is unvalidated.** Confirmation now reads only output produced after + the clear, which structurally excludes reset's own text — that class of false positive is closed. But I + have never observed what a real Claude Code `/clear` actually emits, so the strings it matches + (`context cleared`, `conversation cleared`, …) are still an educated guess. `clear-confirmed` should be + read as *"the harness said something clear-like"*, never as proof the context was discarded. The live + e2e is what will settle it, and the pattern may need adjusting afterwards. This is why confirmation is + advisory and report-only: the re-orientation is correct either way. +- **`codev/` and `codev-skeleton/` copies of `agent-farm.md` carry ~375 lines of pre-existing drift** + unrelated to this work. The sections added here are byte-identical across both trees; the existing drift + is out of scope and left for a reconciliation pass. + +## Flaky Tests + +None encountered. No tests were skipped by this work. + +## Testing + +- 3976 unit tests passing, 48 pre-existing skips; build clean. +- `afx reset --help`, and the invalid-flag rejections (`--quiet-window=-1`, `--timeout=nope`, + `--min-bytes=0` each exit 1), verified against the real `afx` binary rather than the build output. +- Not run: the live end-to-end reset (see Known gaps). diff --git a/codev/roles/builder.md b/codev/roles/builder.md index be5d7cbf5..15bb1f8d0 100644 --- a/codev/roles/builder.md +++ b/codev/roles/builder.md @@ -193,6 +193,33 @@ Can't find the auth helper mentioned in spec. Options: Waiting for Architect guidance. ``` +## Waiting on external work + +The section above covers being blocked on *the architect*. This one covers being blocked on *an +artifact* — a file another agent is producing, a build, a queue, a sibling builder's output. That case +has its own failure mode, and it is the one that strands builders. + +**A wait is a claim that a producer exists.** Before waiting on an artifact, confirm the process meant to +produce it is actually alive. In the incident that motivated this guidance (2026-07-27), a builder waited +45+ minutes on a file whose producing process had already died. The wait could never have succeeded; it +was not slow, it was unsatisfiable. Checking first costs seconds. + +**Run waits as tracked background tasks that end your turn.** Start the wait in the background and finish +your turn. You are re-invoked when it completes, so the lane keeps moving *and* you stay addressable in +the meantime. A turn that ends is a turn someone can interrupt. + +**Never chain foreground poll loops.** This is the rule that matters most, and the reason is not +efficiency. Every `afx send` to you — including the architect's order to stop, including a reset +request — **queues unread until your current turn ends**. A turn that never ends is a builder that cannot +be reached by anyone, doing work nobody can redirect. You will not notice, because from inside the turn +everything looks fine. + +**If you are wedged anyway, you are not unreachable.** The architect can send you an ESC keystroke with +`afx interrupt <your-id>`, which ends the running turn so your queued messages process. They can also run +`afx reset <your-id>` to have you save your working state, clear your context, and be re-oriented — the +supported recovery when your context window is exhausted rather than merely stuck. Neither requires you +to do anything; both are worth knowing exist, so you can suggest them when you notice you are in trouble. + ## Multi-PR Workflow Builders may submit multiple sequential PRs within a single worktree session. The worktree persists across PRs -- it is not cleaned up automatically after merge. This allows builders to do follow-up work (e.g., addressing review feedback in a second PR, or splitting large features across checkpoint PRs). diff --git a/codev/specs/1273-builder-context-reset-should-b.md b/codev/specs/1273-builder-context-reset-should-b.md new file mode 100644 index 000000000..99f98a0e5 --- /dev/null +++ b/codev/specs/1273-builder-context-reset-should-b.md @@ -0,0 +1,620 @@ +# Specification: Builder context reset as a first-class flow (`afx reset`, `afx interrupt`, wait discipline) + +## Metadata +- **ID**: spec-2026-07-28-builder-context-reset +- **Status**: draft +- **Created**: 2026-07-28 +- **Issue**: #1273 +- **Protocol**: ASPIR (strict) + +## Clarifying Questions Asked + +No clarifying round was needed — the issue body, its first comment, and a direct architect +instruction (2026-07-28) together specify the work. Recorded here because they *are* the +requirements and later phases must not drift from them: + +| Question | Answer (source) | +|---|---| +| Is the comment's `afx interrupt` + wait-discipline ask in scope, or a separate project? | **In scope.** "the issue body (afx reset) plus its comment (afx interrupt + wait-discipline docs) together are your requirements" (architect, 2026-07-28). | +| How should effort be distributed across the three deliverables? | "interrupt is small and should not be over-designed; reset is where the design effort goes" (architect). | +| What is the bar for `afx reset`'s two known failure modes? | They "must be impossible by construction" — not merely unlikely (architect). | +| How much porch context must re-orientation carry? | Enough that a porch-strict lane can resume its phase — the intent behind "re-inject phase context like `--resume` does" (issue + architect). Note the wording is about *intent*, not mechanism: `--resume` re-attaches a saved conversation and injects nothing (see Current State). Reset must therefore build the frame itself, which is what invariant R3 requires. | +| Where does the wait-discipline guidance live, given spir-1252 is restructuring prompt surfaces concurrently? | "put the guidance in the SKELETON tree as the primary copy and keep the `codev/`-side change minimal" (architect). | +| Is the ESC-into-PTY recovery verified, or a hypothesis? | Verified in production on 2026-07-27 in the shannon workspace: `afx send <builder> --raw "$(printf '\x1b')"` unwedged a builder within two minutes (issue comment). | + +## Problem Statement + +Long-running builders exhaust their context window repeatedly, and Codev has no first-class way +to give one a fresh window without losing what it knows. Two distinct wedges were hit on the same +day (2026-07-27, shannon workspace), and both were recovered by hand using knowledge that lived +only in architect lore: + +**1. Context exhaustion.** `afx spawn --resume` reattaches the *same* conversation — a deep +session resumes deep. The only way to get a builder a fresh window while preserving its working +state is a four-step manual dance: instruct it to write its state to an untracked file, verify +the file, send `/clear` raw, then hand-write a re-orientation message. Two steps in that dance +are silently destructive if they go wrong: + +- **Clearing before the save lands** destroys the builder's working state irrecoverably. The + architect's only defence today is eyeballing the file ("ours was 203 lines"). +- **A re-orientation that omits the role/protocol frame** leaves a builder with a fresh window, + no idea it is a builder, no protocol, and no porch phase — it drifts, and the drift is not + obvious until it has produced off-protocol work. + +**2. Turn-that-never-ends.** A porch-strict builder chained foreground `until [ -f … ]; do sleep 15; done` +loops inside a single turn for 45+ minutes, waiting on a consult verdict file whose producing +process had already died. Because the turn never ended, every `afx send` — including the +architect's unstick order — sat queued and unread. The builder was unreachable by every +documented channel. The recovery that worked was an ESC keystroke into the PTY +(`afx send <builder> --raw "$(printf '\x1b')"`), which interrupts the running tool, ends the turn, +and lets the queued messages process. + +Both wedges are systemic, not incidental: the first has no tooling at all, the second has tooling +that had to be *discovered* under pressure rather than reached for, and the behaviour that caused +it (chained foreground waits) is not warned against anywhere a builder can see. + +## Current State + +### Context reset — entirely manual + +There is no reset command. The verified manual sequence is: + +1. Architect messages the builder: write complete working state to an **untracked** file at the + worktree root — role, receipts, open questions, standing orders — written for a cold reader. + (Untracked because `porch done` sweeps staged files.) +2. Architect verifies the file exists and is substantive. +3. Architect sends `/clear` via `afx send <builder> --raw`. +4. Architect sends a re-orientation message: role + protocol + worktree/branch, a pointer to the + state file, and anything that post-dates the save. + +Every step is architect-typed, unverified, and reproducible only from memory. + +### What `--resume` actually does (and does not do) + +Relevant because the issue asks reset to "re-inject the porch phase context the same way `--resume` +does", and the real behaviour is narrower than the name suggests: + +- When a resumable prior session is found, `startBuilderSession` launches the harness's resume form + (`claude --resume <uuid>`) and **skips both the role injection and the initial prompt entirely** — + they are already inside the saved conversation. Nothing is re-injected. +- The full builder prompt (role frame + protocol template + resume notice, assembled by + `buildPromptFromTemplate` and `buildResumeNotice`) is injected only on the *fresh-launch fallback*, + when no resumable session exists. +- So the machinery that produces a correct, complete builder frame exists and is well-tested — it is + just not on the path a reset would take today. + +### Adjacent facts that constrain the design + +- **The role survives `/clear`.** The Claude harness injects the role via `--append-system-prompt`, + a process-level flag. `/clear` clears conversation history, not the system prompt. Only the + initial *user* prompt — the protocol/spec/porch frame — is lost. This materially narrows what + re-orientation must restore, and it is Claude-specific. +- **ESC survives the send path by accident.** `POST /api/send` does `message.trim()` and rejects an + empty result; `\x1b` is not JS whitespace, so it passes through. The verified recovery depends on + that, and nothing currently protects it. +- **`--interrupt` exists but is a different signal.** `afx send --interrupt` writes `\x03` (Ctrl+C) + and bypasses the send buffer. Ctrl+C is a harder signal than ESC and is not what was verified to + unwedge a builder mid-turn. +- **Addressing is already solved.** `resolveTarget` resolves short ids (`1273` → `builder-aspir-1273`) + by tail-match with leading-zero stripping, reports `AMBIGUOUS` on collisions, and is + affinity-aware. Any new command must reuse it rather than re-implement addressing. +- **The registry already holds the frame inputs.** The `builders` table carries worktree, branch, + protocol, issue number, terminal id, type and spawning architect. +- **`.builder-*` is a load-bearing prefix.** `afx cleanup` classifies untracked `.builder-*` files as + scaffold, not dirt, so a worktree carrying them is still considered clean. +- **PTY state is observable.** `GET /api/terminals/:id/output` and `PtySession.lastDataAt` (Spec 467) + allow a command to verify what a builder's terminal actually did, instead of blind-firing. +- **Message delivery is paced.** Messages of ≥4 lines are written line-by-line at 10ms intervals to + dodge paste detection (#584), with a 48KB cap. Large payloads over the message channel are slow + and carry paste-detection risk; the spawn path avoids this by writing a prompt file and having the + launch script `cat` it. + +### Wait discipline — architect lore only + +Nothing in the builder role or any protocol document tells a builder how to wait on an external +artifact. The guidance that would have prevented the second wedge — waits belong in tracked +background tasks that end the turn; verify the producer is alive before waiting; never chain +foreground poll loops — exists today only in the architect's head and in this issue. + +## Desired State + +Three deliverables, sized deliberately differently. + +### 1. `afx reset <builder>` — the designed one + +One command runs the whole sequence with the destructive steps gated on verified evidence: + +``` +afx reset 1273 +afx reset 1273 --note "PR #1280 merged since your last save; rebase before continuing" +afx reset 1273 --dry-run # print the assembled payloads, touch nothing +afx reset 1273 --interrupt-first # builder is wedged mid-turn; ESC before asking for the save +``` + +Sequence: + +1. **Resolve and validate.** Resolve the builder through the same resolver `afx send` uses. Read + worktree, branch, protocol, mode and issue from the registry. Confirm the terminal exists and + is writable. Confirm the harness supports in-session context reset — if it does not, **abort + loudly**; do not improvise an alternative. +2. **Assemble the re-orientation first.** Build the complete re-orientation payload — role frame, + protocol and mode, project/issue, worktree and branch, porch re-entry instruction, the state-file + pointer, and any architect addendum — before anything is sent to the builder. Assembly failure + aborts with the builder untouched. +3. **Request the save.** Send a templated save-state request carrying a fresh nonce, the exact + target path (`.builder-state.md` at the worktree root), and a cold-reader content checklist. +4. **Wait for a verified receipt.** Poll the state file until it contains *this run's* nonce, is + substantive, and is size-stable across consecutive polls. Timeout aborts without clearing. +5. **Quiesce.** Confirm the builder's terminal has stopped producing output before typing into it, + with the bounded escalation defined below. Failure to reach quiescence aborts without clearing. +6. **Clear.** Send `/clear` raw. +7. **Re-orient.** Deliver the payload assembled in step 2. +8. **Report.** Print what was verified — nonce receipt, state-file size, clear confirmation status, + re-orientation delivery — so the architect can audit the reset rather than trust it. + +**The default path assumes an addressable builder.** In the common case the builder is idle or +between turns, the save request arrives as an ordinary message, and steps 3–4 proceed normally. +`--interrupt-first` exists specifically for the *wedged* case (issue comment's turn-that-never-ends), +where no message can be read until the turn is broken. Reset does not guess which case it is in: the +default is the addressable path, and the wedged path is opt-in. + +#### Quiescence: bounded escalation, then abort + +Typing into a terminal that is still producing output risks the keystrokes landing inside a running +turn instead of at the prompt. Because "fail fast, no fallbacks" is a fixed constraint, this step has +an explicit, bounded, MUST-level contract: + +1. Observe the terminal for a **quiet window** — no PTY output for a configured interval — within a + bounded first wait. +2. If quiescence is not observed in that wait, send **one** ESC interrupt. This is a defined step in + the state machine, not a recovery improvisation: the state receipt has already been verified, so + there is nothing left to lose by ending the turn. +3. Observe for the quiet window again, within a second bounded wait. +4. If quiescence is still not observed, **abort non-zero without clearing**. The builder keeps its + context, the verified state file remains on disk, and the report names quiescence as the failing + step so the architect can finish manually or retry. + +There is no third attempt and no "clear anyway" path. + +#### Re-orientation payload: what is inlined vs referenced + +To remove ambiguity about what "role frame" means and to stay inside the message channel's pacing +and size limits, the payload has two parts with a fixed division: + +**Inlined in the message (compact, always delivered, satisfies R3 on its own):** +- A statement that the context was reset and the prior conversation is gone. +- **Role frame** — an identity block: *that* the recipient is a builder and which role document + governs it. This is deliberately **not** the full role document text: under the Claude harness the + role is injected via `--append-system-prompt` and survives `/clear` intact, and re-sending + hundreds of lines through a paced, paste-detection-prone channel would be both slow and risky. +- Protocol name and mode (strict/soft), project id and issue number. +- Worktree path and branch name. +- Porch re-entry instruction for porch-driven lanes (`porch next`), mirroring `buildResumeNotice`. +- Pointer to `.builder-state.md`, with an explicit instruction to read it in full before acting. +- The architect addendum from `--note` / `--file`, if supplied. + +**Referenced via a worktree file (`.builder-reorient.md`, long form):** +- The full assembled re-orientation, including any protocol/phase context too large to inline. + Written before the clear (R1) and pointed at from the inlined message. + +`--file <path>` reads from the **architect's** filesystem — the caller's working directory, exactly +as `afx send --file` does — and its contents become part of the addendum. The worktree-containment +rule applies only to an override of the *state-file* path (where reset asks the builder to write), +which must stay inside that builder's worktree. + +### 2. `afx interrupt <builder>` — the small one + +``` +afx interrupt 1273 +afx interrupt 1273 --no-enter +``` + +Writes the verified ESC keystroke into the builder's PTY, bypassing the send buffer. It is the +documented, reachable form of the recovery of record — nothing more. + +### 3. Wait discipline in the builder role + +A short section in the builder role document (skeleton primary, mirrored minimally into `codev/`) +stating the three rules, with the reasoning that makes them stick: + +- A wait is a **claim that a producer exists** — verify the producing process is alive before + waiting on its artifact. +- Waits on external artifacts run as **tracked background tasks that end the turn**; re-invocation + on completion keeps the lane moving *and* keeps the builder addressable. +- **Never chain foreground poll loops.** A turn that never ends makes every `afx send` — including + the order to stop — queue unread. +- If you are wedged anyway, the architect can reach you with `afx interrupt`. + +## Stakeholders + +- **Primary Users**: Architects operating long-running builder lanes (ASPIR/SPIR multi-phase work, + coordinator builders). +- **Secondary Users**: Builders — they receive the save request and the re-orientation, and they are + the audience for the wait-discipline guidance. +- **Technical Team**: Codev maintainers (`area/tower`). +- **Business Owners**: Repository owner (M Waleed Kadous). + +## Success Criteria + +### Functional — `afx reset` (MUST) + +- [ ] `afx reset <builder>` accepts the same address forms `afx send` accepts (full id, short id, + leading-zero variants) and reports the same `NOT_FOUND` / `AMBIGUOUS` errors. +- [ ] **Invariant R1 (assemble-before-destroy)**: `/clear` is never written to a builder's terminal + unless the complete re-orientation payload has already been assembled successfully. Any + assembly failure aborts before the builder is touched at all. +- [ ] **Invariant R2 (fresh-receipt gate)**: `/clear` is never written unless the state file has been + verified to (a) contain *this run's* nonce, (b) meet a minimum-substance threshold, and (c) be + size-stable across consecutive observations. A stale file left by a previous reset MUST NOT + satisfy the gate. +- [ ] **Invariant R3 (complete frame)**: the re-orientation payload always contains role frame, + protocol name and mode, project/issue identity, worktree path, branch name, the state-file + pointer, and — for porch-driven lanes — the porch re-entry instruction. There is no code path + that emits a partial frame; a missing input is an abort, not an omission. +- [ ] Timeout waiting for the state file aborts non-zero, leaves the builder's context intact, and + prints the exact recovery options. +- [ ] **Invariant R4 (quiescence-or-abort)**: `/clear` is never written to a terminal that has not + been observed quiet for the configured window. Failure to reach quiescence — after exactly one + ESC escalation, itself permitted only *after* the R2 receipt — aborts non-zero without + clearing. There is no "clear anyway" path. +- [ ] The inlined re-orientation carries the identity block, protocol, mode, project/issue, worktree, + branch, porch re-entry and state-file pointer; the long form is written to a `.builder-` + prefixed worktree file and referenced. "Role frame" means the identity block, not the full + role document. +- [ ] `--file <path>` reads from the caller's filesystem (like `afx send --file`); only an override + of the *state-file* path is constrained to stay inside the target builder's worktree. +- [ ] `--dry-run` prints both the save request and the assembled re-orientation and writes nothing + to the builder, making R1/R3 auditable by inspection. +- [ ] `--note <text>` (and/or `--file <path>`) appends architect-supplied content that post-dates the + save to the re-orientation. +- [ ] `--interrupt-first` sends the ESC interrupt before the save request, for a builder already + wedged mid-turn. +- [ ] Artifacts written into the worktree use the `.builder-` prefix so `afx cleanup` continues to + classify the worktree as clean, and they are untracked so `porch done`'s staged-file sweep + cannot pick them up. +- [ ] A harness with no in-session context reset produces a loud, actionable failure — no silent + substitution of a different mechanism. +- [ ] The command reports what it verified at each step; unconfirmed steps are reported as + unconfirmed rather than presented as success. + +### Functional — `afx interrupt` (MUST) + +- [ ] `afx interrupt <builder>` writes ESC to the resolved builder's PTY, bypassing the send buffer + so a mid-turn builder receives it immediately. +- [ ] Byte-for-byte equivalent to the verified recovery `afx send <builder> --raw "$(printf '\x1b')"`, + including the trailing Enter (which is what lets already-queued messages process). `--no-enter` + suppresses the Enter. +- [ ] A regression test pins the invariant that the ESC byte survives the send path's `trim()` and + empty-message rejection. + +### Functional — wait discipline (MUST) + +- [ ] The three rules land in the builder role document in the **skeleton** tree as the primary copy. +- [ ] The `codev/` tree receives the same content as a purely additive block — minimal surface, so it + rebases cleanly if spir-1252 removes `codev/` shadow copies. +- [ ] The guidance names `afx interrupt` as the recovery when a builder is wedged anyway. + +### Non-functional + +- [ ] Unit tests cover the reset state machine's ordering invariants (R1, R2, R3, R4) directly — + each invariant has a test that fails if the ordering is inverted or the gate is bypassed. +- [ ] No change to existing `afx send` behaviour. +- [ ] Documentation updated: `codev/resources/commands/agent-farm.md` and the `afx` skill reference, + so the commands are discoverable at the point of use. + +## Constraints + +### Architect directives (fixed — do not relitigate) + +Recorded from the 2026-07-28 architect instruction. The issue carries no `## Baked Decisions` +section; these are the equivalent and are treated as fixed: + +1. Issue body **and** first comment are both in scope. +2. `afx interrupt` is small and must not be over-designed. `afx reset` is where the design effort goes. +3. `afx reset`'s two failure modes must be impossible **by construction**, not merely unlikely. +4. Porch-strict lanes must re-inject phase context the way `--resume` does. +5. Wait-discipline guidance goes in builder-facing role/protocol docs, with the **skeleton tree as + the primary copy** and a minimal `codev/`-side change, to avoid colliding with spir-1252. + +### Technical Constraints + +- **Fail fast, no fallbacks** (repository standing rule). Every unverifiable step aborts loudly + rather than proceeding on an assumption. +- `/clear` is a Claude Code slash command. Reset's in-session mechanism is Claude-harness-specific + and must say so rather than pretend to be harness-agnostic. +- Message delivery is paced (10ms/line above 4 lines) and capped at 48KB, and multi-line writes risk + paste detection. Large re-orientation content should not be pushed through the message channel. +- Never hand-edit `status.yaml`; reset must not touch porch state. It re-orients a builder *toward* + porch (`porch next`), it does not move porch itself. +- Builder addressing must go through the existing resolver — no second addressing implementation. +- Tower must be running; a non-writable terminal is a hard failure (#1198 established that a + silently-dropped write is worse than an error). +- Framework changes must be mirrored across `codev/` and `codev-skeleton/` per repository + convention — subject to directive 5's "skeleton primary, codev minimal" shaping for the docs. + +### Business Constraints + +- Scope is bounded to the three deliverables above. Related prior art (the `/exit` + `spawn --resume` + boundary-recycle pattern, #1260's target-by-convention discussion) informs the design but is + not in scope to change. + +## Assumptions + +- Builders reliably obey a direct, well-formed instruction to write a file — this is the same trust + the spawn prompt already depends on, and it was exercised successfully in the manual run. +- `--append-system-prompt` role content survives `/clear` in Claude Code. Reset does not *depend* on + this (R3 requires the frame in the re-orientation regardless), but it is why the compact + re-orientation was sufficient in the manual run. +- `GET /api/terminals/:id/output` is sufficient to observe quiescence and, best-effort, to confirm a + clear. Where confirmation is not possible, reset reports it as unconfirmed. +- The concurrent spir-1252 work may delete `codev/` shadow copies of prompt surfaces; the doc change + is shaped to survive that. + +## Solution Approaches + +The interesting design space is `afx reset`. Three mechanisms were considered for "give the builder +a fresh window without losing its state". + +### Approach A: In-session clear (`/clear`) + assembled re-orientation — **recommended** + +**Description**: Keep the same terminal, PTY and agent process. Gate the clear on a nonce-verified +state-file receipt, assemble the re-orientation before clearing, send `/clear` raw, then deliver a +compact frame message that also points at the full assembled re-orientation written into the +worktree. This automates the verified manual recipe and hardens its two failure points. + +**Pros**: +- It is the **verified** sequence — it worked in production on a real coordinator builder. +- Terminal identity is preserved: dashboard rows, VSCode tabs and shellper attachment all survive, + with no stale-tab churn (#991 territory). +- The role frame survives untouched in the system prompt. +- Scrollback is preserved, so the architect can audit what the builder did before the reset. +- Both failure modes are closable by construction: R2 (nonce receipt) closes clear-before-save; + R1+R3 (assemble first, complete-or-abort) close frame omission. +- No process restart means no window in which the builder is absent from the registry. + +**Cons**: +- `/clear` is Claude-specific; other harnesses must be refused explicitly. +- Re-orientation arrives as a message, so it is subject to pacing/paste-detection limits — mitigated + by keeping the message compact and putting the long form in a worktree file. +- Confirming the clear actually took effect depends on scanning terminal output, which is + version-sensitive; may end up reported as "unconfirmed". + +**Estimated Complexity**: Medium +**Risk Level**: Low + +### Approach B: Session restart (`afx spawn --reset`) + +**Description**: Kill the builder's terminal session and start a new one on the fresh-launch path +(no `--resume`), reusing the entire spawn prompt-assembly and delivery machinery, with the initial +prompt extended by the state-file pointer. + +**Pros**: +- Highest-fidelity re-orientation available: the builder receives a genuine spawn-quality prompt + through the same channel a fresh spawn uses (prompt file `cat`-ed at launch — no pacing limits, + no paste detection). +- Harness-agnostic; needs no in-session clear command. +- R3 is satisfied trivially — it *is* the spawn path. + +**Cons**: +- New terminal id: dashboard rows and VSCode tabs churn, scrollback is lost, and there is a window + where the builder's terminal is gone. +- Heaviest of the three; touches session lifecycle, which is the most incident-prone area of Tower. +- Diverges from the verified recipe, so the "known-good" evidence does not transfer. + +**Estimated Complexity**: High +**Risk Level**: Medium + +### Approach C: In-PTY relaunch (`/exit` + Enter) + +**Description**: Exploit the existing launch loop in `.builder-start.sh`. Rewrite the prompt file +(and, if the builder was previously resumed, the launch script) to carry the reset prompt, send +`/exit`, then send Enter at the loop's "Press Enter to relaunch" pause. The agent relaunches in the +same PTY with a fresh conversation and the freshly written prompt. + +**Pros**: +- Same terminal id *and* spawn-quality prompt delivery — combines A's and B's main advantages. +- Uses machinery that already exists and is exercised on every builder exit. +- Closest to the documented boundary-recycle prior art. + +**Cons**: +- Depends on the exact launch-loop tail text and its `read -r` pause — a brittle coupling to a + bash string, and a missed Enter leaves the builder parked at a prompt doing nothing. +- Requires rewriting `.builder-start.sh` for builders currently on the `--resume` launch form, or + the relaunch silently re-attaches the deep session — the exact failure the feature exists to avoid. +- Multi-step PTY handshake with more states to verify than A. + +**Estimated Complexity**: Medium-High +**Risk Level**: Medium + +### Recommendation + +**Approach A**, with B and C recorded as considered alternatives. A automates a sequence that is +already verified end-to-end, preserves terminal identity, and — critically — the architect's +"impossible by construction" bar is met by *ordering and evidence* (R1/R2/R3), which is orthogonal +to the mechanism. Adopting B or C would trade proven behaviour for prompt-delivery fidelity that A +recovers cheaply by writing the long-form re-orientation to a worktree file. C remains the natural +escalation if message-channel delivery proves unreliable in practice. + +### `afx interrupt` — deliberately one approach + +Per directive 2, no design exploration: a thin command that resolves the builder through the +existing resolver and writes the exact verified byte sequence, bypassing the send buffer. Explicitly +**out of scope**: `--all` broadcast, interrupt-then-message composition (already available via +`afx send --interrupt`), and any escalation ladder (ESC → Ctrl+C → kill). + +## Open Questions + +### Critical (Blocks Progress) +- None. The design is determined by the issue, its comment, and the architect directives. + +### Important (Affects Design) +- [ ] What is the right minimum-substance threshold for the state file, and what quiet-window and + wait durations does R4 use? The one verified state-file example was 203 lines. Byte and + duration defaults with flag overrides are proposed; the concrete values belong in the plan, + with justification. The *semantics* are fixed here — only the numbers are open. +- [ ] Can `/clear` be positively confirmed from terminal output across Claude Code versions, or must + it be reported as best-effort? Decide in the plan; the answer changes only the reporting, never + the ordering. +- [x] **Resolved**: the re-orientation is delivered as a compact inline frame **plus** a pointer to + the long form in a worktree file. The inline frame alone satisfies R3; the file carries + anything too large for the paced message channel. See "Re-orientation payload" above. + +### Nice-to-Know (Optimization) +- [ ] Should a reset be recorded in the builder registry (e.g. a reset count/timestamp) so the + dashboard can show that a builder has been recycled? Useful signal, not required. +- [ ] Should `afx reset` be reachable from the VSCode builder context menu, as `afx dev` is? + +## Performance Requirements + +- `afx interrupt`: the ESC byte reaches the PTY in well under a second; it must not be subject to + send-buffer deferral under any circumstances. +- `afx reset`: dominated by the builder's own turn latency. The state-file wait needs a generous + default timeout (a busy builder may take minutes to reach the request) and must poll at an + interval that does not hammer the filesystem. No throughput requirements. + +## Security Considerations + +- **No new addressing surface.** Reset and interrupt resolve through the existing resolver, so the + builder→architect spoofing checks and workspace scoping are unchanged. +- **Writing into a builder's PTY is privileged.** Both commands must operate only on builders in the + caller's workspace, exactly as `afx send` does. +- **Path safety.** The state-file path is derived from the registry's worktree, not from user input; + any override must be validated to stay inside the worktree. +- **No secret exfiltration.** The state file stays inside the worktree, is untracked, and is never + transmitted anywhere. `--dry-run` prints only the assembled payloads. +- **Content is builder-authored.** The state file is read only for size/nonce verification — its + contents are never executed or interpolated into a shell command. +- **Content *quality* is not programmatically verifiable, and reset does not pretend otherwise.** + The R2 gate is deliberately structural (nonce + minimum size + stability): a builder writing + fluent but useless prose passes it. Quality is delegated to the content checklist in the save + request, to the reported file size, and to the architect's `--dry-run` inspection of the request + wording. Deeper validation — parsing the file, scoring its completeness — is explicitly out of + scope; a gate that guesses at prose quality would produce false rejections on a file that took the + builder real work to write. + +## Test Scenarios + +### Functional Tests + +1. **Happy path**: builder receives the save request, writes a nonce-bearing substantive file; reset + verifies, clears, and delivers a complete re-orientation; report lists every verified step. +2. **Clear-before-save is impossible (R2)**: state file never appears → reset times out, exits + non-zero, and no `/clear` was written to the terminal. +3. **Stale file rejected (R2)**: a substantive state file from a *previous* reset (wrong nonce) is + present at start → reset does not accept it; it waits for the fresh nonce and times out if none + arrives. +4. **Partial file rejected (R2)**: file appears with the nonce but still growing → reset waits for + size stability rather than accepting a truncated save. +5. **Undersized file rejected (R2)**: a nonce-bearing three-line stub does not satisfy the gate. +6. **Frame omission is impossible (R1/R3)**: with a registry input missing (e.g. no protocol + recorded), reset aborts during assembly, before any write to the builder. +7. **Complete frame content (R3)**: assembled re-orientation contains role frame, protocol, mode, + project/issue, worktree, branch, state-file pointer, and — for a porch-strict lane — the porch + re-entry instruction. +8. **Ordering (R1)**: a test that inverts the sequence (clear before assembly) fails, proving the + ordering is enforced rather than incidental. +9. **Addressing parity**: short id, full id and leading-zero forms resolve identically to `afx send`; + an ambiguous short id produces `AMBIGUOUS`. +9a. **Quiescence abort (R4)**: a terminal that never goes quiet → exactly one ESC escalation, then + abort non-zero with no `/clear` written; the report names quiescence as the failing step. +9b. **Quiescence escalation ordering (R4)**: the ESC escalation never fires before the R2 receipt is + verified — a test that moves it earlier fails. +9c. **Double reset**: running reset against a builder still processing a previous re-orientation → + the previous run's state file carries the wrong nonce, so R2 rejects it and the run waits for a + fresh save (or times out) rather than accepting superseded state. +10. **Unsupported harness**: a non-Claude builder produces a loud abort naming the harness, with no + writes to the terminal. +11. **Non-writable terminal**: reset fails loudly rather than reporting success for dropped writes. +12. **`--dry-run`**: prints both payloads, performs zero writes to the builder and zero worktree + mutations beyond nothing at all. +13. **`--note` / `--file`**: architect addendum appears in the re-orientation. +14. **`--interrupt-first`**: ESC precedes the save request. +14a. **Wedged-builder end-to-end** (integration): a builder stuck mid-turn → `--interrupt-first` + breaks the turn → the save request is read → the normal reset flow completes. This is the + headline recovery scenario and deserves coverage beyond the unit-level ordering test. +15. **`afx interrupt` byte sequence**: writes ESC then Enter, bypasses the send buffer; `--no-enter` + writes ESC alone. +16. **ESC survives the send path**: regression test pinning that a lone `\x1b` message is not + trimmed to empty and not rejected. +17. **Cleanup classification**: a worktree containing reset's artifacts is still reported clean by + `afx cleanup`'s scaffold-only check. + +### Non-Functional Tests + +1. **Docs mirrored**: the wait-discipline section exists in the skeleton builder role and in the + `codev/` copy, with equivalent content. +2. **No regression in `afx send`**: existing send tests pass unchanged. +3. **Discoverability**: the commands appear in `afx --help` and in the agent-farm command reference. + +## Dependencies + +- **External Services**: none. +- **Internal Systems**: Tower (`POST /api/send`, `GET /api/terminals/:id/output`), the message + resolver, the builder registry (`global.db`), the harness abstraction, porch (read-only, for + phase context), `afx cleanup`'s scaffold classification. +- **Libraries/Frameworks**: existing stack only — no new dependencies. + +## References + +- Issue #1273 (body: `afx reset`; first comment: `afx interrupt` + wait discipline; second comment: triage). +- Architect instruction, 2026-07-28 (scope, sizing, doc placement, merge pre-grant). +- Prior art: the `/exit` + `spawn --resume` boundary-recycle pattern; #1260 target-by-convention. +- Concurrent work: spir-1252 (prompt-surface restructuring) — shapes where the doc change lands. +- Related incidents: #1198 (dropped writes must fail loudly), #584 (paste detection / paced writes), + #1094 (unverified identity must not be laundered into a misroute), #991 (stale terminal tabs). + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation Strategy | +|---|---|---|---| +| Builder writes a stub state file that passes the size gate but is useless to a cold reader | Medium | High | Content checklist in the save request; size + stability gate; `--dry-run` lets the architect inspect the request wording; report prints the file size so a suspicious save is visible. | +| `/clear` cannot be positively confirmed, so reset reports success on a clear that did not happen | Medium | Medium | Report the step as *unconfirmed* rather than successful; the re-orientation is self-describing and correct even if the clear failed (worst case: no context saving, no data loss). | +| Coupling to Claude Code's `/clear` breaks on a future version | Low | Medium | Single, documented coupling point; harness-gated; the failure is visible in the report rather than silent. | +| ESC delivery breaks if `POST /api/send`'s trimming changes | Low | High | Explicit regression test pinning the invariant (test 16). | +| Doc change collides with spir-1252's restructuring | Medium | Low | Skeleton is the primary copy; the `codev/`-side change is a small additive block that rebases cleanly. | +| Reset races an in-flight porch phase transition | Low | Medium | Reset never touches porch state; it re-orients toward `porch next`, which re-reads authoritative state on the other side. | +| Architect resets a healthy builder mid-turn and truncates useful work | Low | Medium | Automatic ESC only after the state receipt is verified (nothing left to lose); pre-emptive ESC is opt-in via `--interrupt-first`. | +| A builder that never goes quiet blocks reset indefinitely, or tempts a "clear anyway" shortcut | Low | High | R4 makes the outcome explicit: one bounded ESC escalation, then abort non-zero with the context intact. Both waits are bounded and configurable. | +| State file passes the structural gate but is useless to a cold reader | Medium | High | Acknowledged as out of scope for programmatic checking (see Security Considerations); mitigated by the content checklist, the reported size, and `--dry-run`. | + +## Expert Consultation + +**Date**: 2026-07-28 +**Models Consulted**: Gemini (agy), GPT-5.4 Codex, Claude +**Verdicts (iteration 1)**: Gemini APPROVE (HIGH), Claude APPROVE (HIGH), Codex REQUEST_CHANGES (HIGH) + +Codex raised two safety-critical gaps, both now closed: + +- *"Quiesce is a required safety gate but has no explicit failure semantics."* → Added **invariant R4** + (quiescence-or-abort) with a bounded escalation contract — one ESC, permitted only after the R2 + receipt, then abort non-zero without clearing — plus success criteria and tests 9a/9b. +- *"The re-orientation payload contract is still ambiguous... `--file` conflicts with the + worktree-containment rule."* → Added the "Re-orientation payload: what is inlined vs referenced" + section, defining "role frame" as the identity block (not the full role document) and separating + `--file` (reads from the caller's filesystem, like `afx send --file`) from the state-file path + override (constrained to the target worktree). + +Claude's non-blocking comments, all incorporated: the default path now explicitly assumes an +addressable builder; the double-reset scenario is covered by test 9c; the `--resume` wording in the +Clarifying Questions table now distinguishes intent from mechanism; the content-quality trade-off is +stated in Security Considerations; a wedged-builder end-to-end integration test was added (14a). + +Gemini's plan-level suggestions: the state file is named in the spec (`.builder-state.md`, fixed +name — freshness comes from the in-file nonce, so no per-reset filename litter); the quiescence +window is now a specified concept with numbers deferred to the plan; the hybrid re-orientation +delivery it endorsed is now the resolved decision rather than an open question. + +## Approval + +ASPIR: spec-approval is auto-approved; the `pr` gate remains human-approved. + +- [ ] Expert AI Consultation Complete + +## Notes + +- Reset deliberately does **not** move porch state. It re-orients a builder toward `porch next`, + which re-reads authoritative state itself. Any temptation to have reset write `status.yaml` is out + of bounds. +- The wait-discipline guidance is scoped to the **builder role** as its single owning surface rather + than fanned out into each protocol's prompts — protocol-prompt ownership is spir-1252's subject, + and duplicating guidance across surfaces is what the ownership map is meant to prevent. diff --git a/codev/state/aspir-1273_thread.md b/codev/state/aspir-1273_thread.md new file mode 100644 index 000000000..ee33576fb --- /dev/null +++ b/codev/state/aspir-1273_thread.md @@ -0,0 +1,369 @@ +# aspir-1273 — Builder context reset as a first-class flow + +**Protocol**: ASPIR (strict, porch-driven). Issue #1273. Worktree `.builders/aspir-1273`, branch `builder/aspir-1273`. + +## Scope (per architect instruction, 2026-07-28) + +Issue body **and** its first comment are both requirements: +1. `afx reset <builder>` — save-state → verify → `/clear` → re-orient. **This is where the design effort goes.** +2. `afx interrupt <builder>` — wrap the ESC-into-PTY recovery. **Small; do not over-design.** +3. Builder-facing **wait discipline** guidance in role/protocol docs. Primary copy in `codev-skeleton/`; + keep the `codev/`-side change minimal to avoid colliding with spir-1252 (concurrently restructuring + prompt surfaces, may delete `codev/` shadow copies). + +Merge is pre-granted for the PR gate given green CI + cleanly-resolved CMAP; anything surprising still +goes to the human. + +## Investigation findings (specify phase) + +Read of the afx/Tower code before drafting: + +- **`afx send` path**: `commands/send.ts` → `POST /api/send` → `handleSend` in `servers/tower-routes.ts:1425`. + `resolveTarget` (`servers/tower-messages.ts:152`) already resolves short builder ids (`1273` → + `builder-aspir-1273`) via tail-match with leading-zero stripping. New commands should reuse it, not + reinvent addressing. +- **ESC survives the send path**: `handleSend` does `body.message.trim()` and rejects empty. JS `trim()` + strips WhiteSpace|LineTerminator; `\x1b` is neither, so the ESC byte survives. That's *why* the manual + recipe works — and it's an implicit invariant worth an explicit regression test. +- **Interrupt already half-exists**: `--interrupt` writes `\x03` (Ctrl+C) then sleeps 100ms and bypasses + the send buffer (`shouldDefer = !interrupt && …`). ESC ≠ Ctrl+C for Claude Code: ESC ends the turn, + Ctrl+C is a harder signal. `afx interrupt` is the ESC variant. +- **Role injection is `--append-system-prompt`** (`utils/harness.ts:118` CLAUDE_HARNESS). It is a process + flag, so **the role frame survives `/clear`** — only the initial *user* prompt (protocol/spec/porch + frame) is lost. That narrows what re-orientation must restore. +- **`--resume` does NOT re-inject anything** when a resumable session is found: `startBuilderSession` + (`commands/spawn-worktree.ts:820`) skips both prompt and role on the resume path (`claude --resume <uuid>`). + The full builder prompt is only injected on the fresh-launch fallback. So "re-inject phase context the + way `--resume` does" = reuse `buildPromptFromTemplate` + `buildResumeNotice`, the fresh-path machinery. +- **Launch loop**: `.builder-start.sh` wraps the agent in `while true`, and a clean `/exit` (status 0) + prints "Press Enter to relaunch" and waits on `read -r`. That makes an in-PTY relaunch (rewrite + `.builder-prompt.txt` → `/exit` → Enter) a real third option for reset. Captured as Approach C. +- **`.builder-*` prefix is load-bearing**: `commands/cleanup.ts:136` treats untracked `.builder-*` files + as "scaffold only, not dirty". Naming the state file `.builder-state.md` keeps cleanup semantics right. +- **PTY observability**: `GET /api/terminals/:id/output` exists; `PtySession.lastDataAt` (Spec 467) gives + output quiescence. Both usable to verify each reset step instead of blind-firing. +- **Message pacing**: `message-write.ts` — ≥4 lines is written line-by-line at 10ms/line. Large + re-orientation payloads are slow-ish and risk paste detection (#584); favour a compact frame message + plus a worktree file for the long form. + +## Design invariants chosen for the spec + +- **R1 (assemble-before-destroy)**: the complete re-orientation payload must be assembled and persisted + *before* `/clear` is written. Nothing destructive happens until the replacement context exists. +- **R2 (nonce receipt)**: the save-state request carries a nonce; the state file is only accepted if it + contains that nonce and is size-stable and substantive. Makes "cleared before the save landed" — and + "accepted a stale file from a previous reset" — impossible rather than unlikely. +- Fail fast on non-Claude harnesses (no in-session clear) rather than inventing a fallback. + +## CMAP iteration 1 (spec) — 2026-07-28 + +Gemini **APPROVE** (HIGH), Claude **APPROVE** (HIGH), Codex **REQUEST_CHANGES** (HIGH). + +Codex's two blocking gaps were both real and both closed: + +1. *Quiesce had no failure semantics.* I had specified "confirm the terminal stopped producing output" + without saying what happens if it never does — which, under "fail fast, no fallbacks", is exactly + the kind of hole that becomes a "clear anyway" shortcut at implementation time. Added **R4**: + bounded wait → exactly one ESC escalation (legal only *after* the R2 receipt, so nothing is at + risk) → bounded wait → abort non-zero, no clear. Plus tests 9a/9b, one of which pins the ESC + ordering against R2. +2. *Re-orientation payload contract ambiguous.* "Role frame" could be read as the full role document + (hundreds of paced lines through a paste-detection-prone channel) or as an identity block. Now + explicitly the latter, with a fixed inline-vs-referenced division. Also separated `--file` + (reads from the *caller's* filesystem, like `afx send --file`) from the state-file path override + (must stay inside the target worktree) — I had conflated the two in Security Considerations. + +Claude verified all ~10 factual codebase claims in the spec independently and found them accurate; +its comments (default-path addressability, double-reset, `--resume` wording, content-quality +trade-off, wedged-builder integration test) are all incorporated. + +Design decisions locked this iteration: +- State file is `.builder-state.md`, **fixed name** — freshness comes from the in-file nonce, not the + filename, so resets don't litter the worktree with `.builder-state-<nonce>.md` files. +- Long-form re-orientation goes to `.builder-reorient.md`; the message carries the compact frame. + Both `.builder-` prefixed, so `afx cleanup` still sees the worktree as clean. + +## Plan CMAP iteration 1 — 2026-07-28 + +Gemini **APPROVE**, Claude **APPROVE**, Codex **REQUEST_CHANGES** (4 issues). All verified against code +before acting; all accepted. Full detail in `1273-plan-iter1-rebuttals.md`. + +**The one that matters for anyone else working in this area** — the builders registry does NOT carry what +you'd assume: + +- `builders.protocol_name` is **NULL for spec-type builders**. `spawn.ts:488-492` never passes + `protocolName`; only the `protocol`-type path (`:620-625`) does. Reading `db/schema.ts` shows the column + and looks fine — the persistence path is where the truth is. Every SPIR/ASPIR lane, including this one, + has NULL there. +- **Mode is nowhere in the DB.** `resolveMode` computes it at spawn from flags + protocol defaults and + discards it; a spawn-time `--soft` is unrecoverable afterwards. +- Where the facts actually live: porch `status.yaml` (protocol, phase), `.builder-prompt.txt` (the literal + `## Mode: STRICT` line), `.builder-start.sh` (the real harness launch line — per-builder ground truth, + unlike workspace config which can change mid-run). + +Added **phase 4 (context resolution)** for this, with per-field precedence chains each ending in a loud +abort. Deliberately did **not** add a `mode` DB column: it would be NULL for every running builder, so the +worktree derivation is needed anyway, and the column would duplicate a fact the worktree already holds. +Flagged to the architect in the rebuttal as a reversible call. + +Also from this round: `longForm` re-orientation is now literally `buildPromptFromTemplate`'s output (the +same function the fresh-launch spawn path uses) rather than a paraphrase, with `buildResumeNotice()` +reused verbatim for the porch re-entry text; `TowerClient.sendMessage` in `packages/core` needs the +`escape` option (it was missing from phase 1); `.codex/skills/afx/SKILL.md` needs updating alongside the +Claude one. + +Phase count 6 → 7. + +## Implement phase 1 (afx interrupt) — 2026-07-28 + +Unanimous APPROVE (Gemini, Codex, Claude — all HIGH, no issues). Commit `5ca14db8`. + +**Worktree setup gotcha for anyone following**: a fresh worktree has no `node_modules` (needed +`pnpm install --frozen-lockfile`) AND `tower-routes.test.ts` will not even load until +`pnpm --filter @cluesmith/codev-core build` has run — the `@cluesmith/codev-core/tower-client` subpath +export resolves into `dist/`. It presents as "Cannot find package", which reads like a dependency +problem rather than a build-order one. + +**Live verification is blocked, and not by anything in the code.** The running Tower is the globally +installed build with no `escape` route handler, so my CLI's `escape: true` would be silently ignored and +the ESC would arrive as an ordinary formatted message — a test that would pass while proving nothing. +A faithful end-to-end run needs `pnpm -w run local-install`, which restarts Tower and affects every +builder in the workspace. That is the architect's call; I have not done it and have notified them. The +same constraint will apply to phase 6's "reset against a disposable builder". + +Minor: Claude's review says `message-write.ts` was extracted in this phase and wasn't in the plan. It +already existed (Bugfix #584); phase 1 only added `writeEscapeToSession` to it. Non-blocking, recorded +here for accuracy rather than rebutted. + +## Implement phase 2 (lastDataAt) — 2026-07-28 + +Iter 1: Gemini APPROVE, Claude APPROVE, **Codex REQUEST_CHANGES**. Iter 2: unanimous APPROVE. +Commits `3ded0a26` (impl) + `a6115771` (test fix). + +**Worth reading if you ever weigh CMAP verdicts by majority**: the two approvers contradicted each +other on the exact point at issue. Codex said the wire-contract test was missing; Claude said +explicitly *"No gap here"* — the handler is a pure `JSON.stringify(session.info)` passthrough, so the +unit test is the right level. Two APPROVEs would have shipped it. + +I sided with the single dissenter. Claude's premise was factually right about today's handler, but +"pure passthrough" is a property of the current code, not of the contract — a later projection, +redaction or version envelope at the route would break the wire while every `session.info` test stayed +green. It was also my own acceptance criterion naming the endpoint. Two cheap tests against a silent +break in the signal R4 consumes before typing `/clear` into a live terminal is an easy trade. On iter 2 +Claude reviewed the reasoning and agreed. + +Lesson candidate for the review: *a majority APPROVE is not consensus — when reviewers disagree, decide +on the argument, and record why.* + +## Implement phase 5 (re-orientation assembly) — iterations 1–6, 2026-07-29 + +Five CMAP rounds on one phase — the most contested in the project. Gemini APPROVEd every round; Codex +REQUEST_CHANGES'd every round; Claude split. Worth recording *why* that pattern held rather than reading +it as reviewer noise: phase 5's contract is "the long form is spawn machinery, not a paraphrase of it", +and every Codex finding was a different way that hand-rolled reconstruction had drifted from what spawn +actually delivers. The verdict pattern was tracking a real recurring defect class, not one bug re-reported. + +Iterations 1–4 (all Codex findings accepted): partial porch/issue frames were possible (fixed with +`conditionalInlineMarkers` — markers required *when the lane supplies the fact*); the porch re-entry text +was restated instead of reusing `buildResumeNotice()` and had silently dropped its `porch init` fallback +(fixed with a `ResumeNoticePort`, embedded verbatim in the long form); porch identity is now validated +field by field. + +**Iteration 5 is the first finding in this project I partially rejected.** Codex claimed a reset PIR +builder gets blank artifact filenames instead of fresh-spawn framing, because PIR's `builder-prompt.md` +consumes `{{artifact_name}}` and my port never supplies it. Checked it: `artifact_name` is not on +`TemplateContext`, no spawn path in `spawn.ts` sets it, only porch supplies it for per-phase prompts, and +`renderTemplate` renders a missing key as `''`. So PIR's prompt renders that placeholder **blank at spawn +today** — reset reproduces spawn exactly, blanks included, which is what spawn-equivalence means. The +premise ("reset degrades PIR framing") does not hold. + +The remedy was adopted anyway, on its own merits: `SpawnPromptPort` is now typed against the canonical +`TemplateContext` rather than a partial local copy. That copy is exactly how issue metadata went missing +in iteration 2 — a duplicated type drifts and no compiler complains. Compile-time coverage of every +present and future field beats the single-field runtime test Codex proposed, which is why the proposed +regression test was declined: it would have asserted behaviour spawn itself does not have. + +**There is a real bug — it is just PIR's, not phase 5's.** PIR's spawn-time prompt has referenced an +unpopulated placeholder since it was written. Fixing it means changing another protocol's spawn path, +inside a phase contracted to *match* spawn. Escalated to the architect for a separate issue rather than +silently widening scope. (Escalation re-sent 2026-07-29 — the original send was not recorded here, and an +unrecorded escalation is indistinguishable from one that never happened.) + +**Resolved 2026-07-29**: architect verified the finding and filed it as **issue #1293** (blank filenames +in PIR spawn prompts), with the recommended fix being to drop the placeholder in favour of porch's +per-phase naming. Keeping it out of 1273's scope was confirmed correct. + +**Iteration 6 — Codex found a real one, and two APPROVEs would have shipped it.** `{{input_description}}` +is the *first content line* of `bugfix`/`air`/`aspir`/`spir` `builder-prompt.md`, and spawn populates it +four different ways (`spawn.ts:455`/`543`/`607`/`837`). My reconstruction produced two. A BUGFIX/AIR lane +has no spec, so it fell through to the fallback and a reset builder was told it was *"running the BUGFIX +protocol"* instead of naming its issue — on exactly the lanes where the issue body **is** the spec. The +fallback also read `the <P> protocol` where spawn writes `running the <P> protocol`, so even the branch I +did implement was not spawn-equivalent. + +Unlike iter 5's `artifact_name`, this field **is** on `TemplateContext` and **is** populated by every +spawn path. That distinction is the whole difference between the finding I rejected and the one I took. + +Found while fixing, and not flagged by anyone: the obvious branch order is wrong. A `--task` builder gets +a porch project keyed on its *builder id*, so `context.ts`'s `issueNumber ?? porch?.projectId` fallback +populates `issueNumber` for it too — testing `issueNumber` before `taskText` would announce a GitHub issue +that does not exist. Order is spec → task → issue → protocol-only, documented at the function. + +Honest note on iter 5's structural fix: **typing the port against `TemplateContext` would not have caught +this.** The field was always present and type-correct, merely *wrong*. Types close the "field went +missing" class; only tests pinned to spawn's literal strings close the "field carries the wrong value" +class. Tests 46 → 51, one per spawn entry point. Commit `ecaf401a`. + +**Iteration 7: unanimous APPROVE — including Codex, who had dissented all six prior rounds.** Phase 5 +closed at 3899 tests, build clean. + +### The phase-5 pattern, for the review + +Seven rounds, six genuine defects, every one found by the same reviewer, and every one an instance of a +single class: **hand-rolled reconstruction of a spawn structure drifting from what spawn actually emits.** +The marker list that omitted project/issue; the restated resume notice that dropped its `porch init` +fallback; the identity assertions that matched the weaker string; the completeness check that matched +labels instead of values; the input framing that covered half the entry points. Twice the majority +APPROVEd a real defect. Lesson candidate, sharper than phase 2's: *when reviewers split, weigh the +argument, not the count — and a reviewer who dissents every round may be tracking a defect class rather +than nitpicking.* + +## Implement phase 6 (reset orchestrator + CLI wiring) — 2026-07-29 + +The orchestrator is a pure state machine over injected ports (`clock`, `fs`, `sendMessage`, `sendRaw`, +`sendEscape`) that records an ordered **step log** of every externally-visible action. Every invariant +test asserts over that log, and the important assertions are of *absence*: no `clear` in an aborted run, +`escalate-esc` never before `receipt-accepted`. A happy-path-only test would pass against an +implementation that clears first and asks questions later — the ordering is the whole safety story. + +**I nearly shipped a silent, total failure, and the phase-5 lesson is what caught it.** I had one +`writeRaw(data)` port bound to Tower's `escape: true`, used for both the ESC escalation and `/clear`. +But `writeEscapeToSession` (`servers/message-write.ts:46`) writes a **hardcoded ESC and discards the +message body**. So `/clear` would have sent an interrupt. Every observable signal still reads as success: +the send returns ok, the terminal goes quiet (an ESC ends the turn), the re-orientation arrives on +schedule. The only thing that would not happen is the reset — the builder keeps its entire context, which +is precisely the outcome the feature exists to prevent, reported as a clean run. + +Nothing in the type system or the tests would have caught it, because the port's shape was right and only +its *binding* was wrong. What caught it was going to read `writeEscapeToSession` instead of trusting that +"escape means raw write" — the same move that resolved iterations 5 and 6 (verify the claim against the +file). The fix is structural: three distinct port methods (`sendMessage` / `sendRaw` / `sendEscape`) so +the confusion is unrepresentable, plus a regression test asserting `/clear` goes down the raw channel and +`escapes === 0`. + +`/clear` via `raw: true` is also what the original manual recipe in the issue used. I had drifted from +the documented procedure and would have been "spawn-shaped, not spawn-equivalent" all over again. + +Also pinned: both artifact names still match `afx cleanup`'s `/^\?\? \.builder-/` scaffold pattern. That +coupling is by filename convention, not a shared symbol — a rename would quietly report every reset +builder's worktree as dirty and block cleanup. + +CLI verified against the real binary (`node packages/codev/bin/afx.js reset --help`), not just the build. + +**Still outstanding, and it is the headline path**: the manual end-to-end run. `afx reset` against a live +builder needs `pnpm -w run local-install` (restarts Tower, affects every builder in the workspace) — +the architect's call, flagged since phase 1 and still open. 3926 tests passing is not the same as "it +works", and I am not claiming otherwise. + +## Implement phases 6–7 complete — 2026-07-30 + +Phase 6 took **7 CMAP iterations**, every one a genuine wrapper-level defect, every one found by Codex +while Gemini and Claude approved. In order: addressing parity (`getBuilder` exact-match, while my own +docstring claimed `afx send` parity) + a `--dry-run` that printed a blank line under its save-request +header; numeric flags that silently **switch off R2/R4** (`--quiet-window -1` makes quiescence pass +instantly; `--timeout nope` yields a NaN deadline that never fires); `readRecentOutput` never bound, so +`/clear` confirmation could only ever pass in tests; the promised scenario-14a coverage skipped without the +annotation the plan required; terminal writability never preflighted; and a vanished terminal misreported +as an old-Tower `lastDataAt` problem. + +**The through-line, stated once because it is the project's main output:** the orchestrator — the +dangerous, carefully-designed part — was right almost immediately. Every defect was in the boring wiring, +and the state-machine tests were *structurally blind* to all of it. A pure state machine over injected +ports cannot tell whether `sendRaw` was bound to Tower's `raw` route or its `escape` route, whether the +builder was found by exact id or the shared resolver, or whether `readRecentOutput` exists in production. +I proved the hard part thoroughly and left the easy part unproven, which is exactly where the bugs were. +`spec-1273-reset-command.test.ts` exists because of that and is now carrying its fourth regression. + +Two of these were **silent-success** failures — the worst class here. `/clear` bound to the escape route +would have reported a clean run while the builder kept its entire context. `--quiet-window -1` would have +cleared a builder mid-turn and printed a tidy step log. Both are the precise outcome the step-log design +exists to make impossible, and both were reachable without touching the state machine. + +**Process bug of my own making, worth recording:** I ran consults directly instead of via `porch next`'s +task, so porch never registered iterations 2–7 — its counter sat at 2 while I was on 7. The consult files +were all genuine and correctly named, so walking porch back through the rounds reconstructed the true +history, but the lesson is plain: **in strict mode, take the consultation task from `porch next`; don't +run the commands yourself and assume porch is watching.** It is not. + +Phase 7 (docs) passed **unanimously on iteration 1** — the only phase to do so. Wait discipline now lives +in both `roles/builder.md` copies (byte-identical), `afx reset`/`afx interrupt` are in both command-doc +trees, and both skill trees are updated. 18 docs tests assert the cross-tree agreement, because "updated +one, forgot the twin" is invisible in review — the file you are reading looks correct. + +Suite 3894 → 3976 across the two phases. Build clean throughout. + +## Review phase + PR #1305 — 2026-07-30 + +PR opened after all seven phases. Four CMAP rounds at the PR, and the shape changed partway through. + +**Rounds 1–3: three real findings, all taken.** Branch was 29 behind `main` (merged, not rebased — +rebasing would rewrite 50 porch-authored state commits, and the repo rule is "do not squash"). Then two +successive false-confirmation bugs in `confirmClear`, which deserve recording together because they are one +defect wearing three masks: + +1. `readRecentOutput` unbound → always **unconfirmed**. A check that never looked. (phase 6, iter 3) +2. Pattern matched `/clear` → always **confirmed**. A check that looked at its own keystroke echo. +3. Pattern matched the save request's `CONTEXT RESET INCOMING` header → always **confirmed**. A check that + looked at its own *message*. + +Each fix was a better regex; each time the regex was the wrong layer. The actual defect was scanning a +buffer that **contains reset's own writes** and hoping the pattern would miss them. Reset writes into that +terminal three times per run — collision is the default, not a risk. Fixed structurally: `readOutput()` now +returns `{ lines, total }`, the orchestrator snapshots `total` before the clear, and confirmation reads +only what came after. Reset's own text is excluded by construction, so no future pattern edit can +reintroduce the class. `PRE_CLEAR_BUFFER` in the harness carries the real save-request header so a +windowing regression fails the negative tests. + +Honest residual, named in the review: **the pattern itself is still unvalidated.** I have never seen what a +real `/clear` emits. The window closes the false-positive class; it cannot prove the pattern ever matches +in production. `clear-confirmed` means "the harness said something clear-like", never proof — which is why +it stays advisory and report-only, with no invariant depending on it. + +**Round 4: first outright rejection of this project.** Two findings, both contradicting documented repo +convention: that the thread file is runtime state to be dropped (the role doc mandates committing it, and +`main` already carries **141** thread files), and that seven commits break the format (CLAUDE.md's own +first example, `[Spec 42] Initial specification draft`, carries no phase segment — spec/plan/review commits +use the plain form; all seven cited commits are correct, and four are porch-generated). The commit-format +point was a re-raise of one I rebutted in round 1. + +Worth stating the distinction plainly, since "builder rejects reviewer" is exactly the move that hides real +problems: the previous six findings were all genuine and all taken, several of them safety-critical. These +two are disagreements with the repo's design, not with my code — so the right response is evidence and an +architect flag, not a branch change. + +Stopping the CMAP loop here. Remaining dissent is a convention disagreement, not an unresolved defect; +another round would be churn. + +**At the pr gate — human approval required. Not approving it myself.** + +## Status + +- [x] Explored afx/Tower internals +- [x] Spec drafted → `codev/specs/1273-builder-context-reset-should-b.md` +- [x] Spec CMAP iteration 1 — 2 APPROVE, 1 REQUEST_CHANGES, all feedback addressed → spec auto-approved +- [x] Plan drafted → `codev/plans/1273-builder-context-reset-should-b.md` +- [x] Plan CMAP iteration 1 — 2 APPROVE, 1 REQUEST_CHANGES, all 4 issues addressed → plan auto-approved +- [x] Phase 1 (afx interrupt) — implemented, 116 tests green, unanimous CMAP APPROVE +- [x] Phase 2 (lastDataAt observability) — 2 iterations, sided with the lone dissenter +- [x] Phase 3 (reset receipt gate) +- [x] Phase 4 (builder context resolution) +- [x] Phase 5 (re-orientation assembly) — 7 iterations, unanimous APPROVE, 3899 tests green +- [x] Phase 6 (reset orchestrator + CLI wiring) — 7 iterations, unanimous APPROVE +- [x] Phase 7 (wait discipline + command documentation) — unanimous APPROVE, iteration 1 +- [x] Review + PR — PR #1305, 4 CMAP rounds, at the pr gate +- [ ] Post-merge: live e2e of `afx reset` in the verify window + +**Open for the review phase**: live end-to-end verification of the ESC path and a real reset against a +disposable builder is still blocked on `pnpm -w run local-install` (restarts Tower, affects every builder +in the workspace). That is the architect's call, and it remains the gap between "3894 tests pass" and +"it works" — flagged since phase 1, still unresolved. diff --git a/packages/codev/src/agent-farm/__tests__/pty-last-data-at.test.ts b/packages/codev/src/agent-farm/__tests__/pty-last-data-at.test.ts index 6ee2820ff..739a06d1f 100644 --- a/packages/codev/src/agent-farm/__tests__/pty-last-data-at.test.ts +++ b/packages/codev/src/agent-farm/__tests__/pty-last-data-at.test.ts @@ -95,3 +95,97 @@ describe('PtySession.lastDataAt (Spec 467)', () => { expect(time2).toBe(time1 + 2000); }); }); + +/** + * Spec 1273: the tracking above has existed since Spec 467, but only as an + * in-process getter — it never reached `info`, which is what + * `GET /api/terminals/:id` serialises. Without it on the wire, a client cannot + * measure output quiescence and `afx reset` would have to *assume* a builder's + * turn had ended before typing `/clear` into its terminal. Invariant R4 requires + * measuring instead of assuming, so these tests pin the field's presence on the + * serialised shape, not merely on the class. + */ +describe('PtySession.info.lastDataAt (Spec 1273 — quiescence on the wire)', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('exposes lastDataAt on info as an epoch-ms number', () => { + const session = createSession(); + + expect(typeof session.info.lastDataAt).toBe('number'); + expect(session.info.lastDataAt).toBe(session.lastDataAt); + }); + + it('advances info.lastDataAt when the PTY produces output', () => { + const session = createSession(); + const before = session.info.lastDataAt; + + vi.advanceTimersByTime(3000); + (session as any).onPtyData('spinner frame'); + + expect(session.info.lastDataAt).toBe(before + 3000); + }); + + it('holds info.lastDataAt steady while the PTY is silent — the quiescence signal', () => { + const session = createSession(); + + (session as any).onPtyData('turn output'); + const atLastOutput = session.info.lastDataAt; + + // A quiet stretch: this is exactly what reset waits for before typing. + vi.advanceTimersByTime(10_000); + + expect(session.info.lastDataAt).toBe(atLastOutput); + expect(Date.now() - session.info.lastDataAt).toBe(10_000); + }); + + it('keeps info.lastDataAt in sync with the getter across successive outputs', () => { + const session = createSession(); + + vi.advanceTimersByTime(1000); + (session as any).onPtyData('one'); + expect(session.info.lastDataAt).toBe(session.lastDataAt); + + vi.advanceTimersByTime(1000); + (session as any).onPtyData('two'); + expect(session.info.lastDataAt).toBe(session.lastDataAt); + }); +}); + +/** + * PtySessionInfo.writable (Spec 1273) + * + * `afx reset` preflights on this field so it refuses a terminal it cannot write + * to before touching anything. That only works if `writable` is actually + * SERIALISED — the getter existed since #1198 but never reached `info`, so no + * client could see it. + */ +describe('PtySessionInfo.writable (Spec 1273)', () => { + it('is serialised in info, not just available as a getter', () => { + const session = createSession(); + expect(session.info).toHaveProperty('writable'); + }); + + it('agrees with the writable getter', () => { + const session = createSession(); + expect(session.info.writable).toBe(session.writable); + }); + + it('reports false while status still says running — the #1198 disagreement', () => { + // This is the exact shape reset preflights against, and it needs no + // contrivance to produce: a session with no live backend has + // `exitCode === undefined`, so `status` is 'running', while `writable` is + // false because there is nothing to write to. A caller trusting `status` + // would send into a void; reset reads `writable` instead. + const session = createSession(); + + expect(session.info.status).toBe('running'); + expect(session.info.writable).toBe(false); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-1273-interrupt.test.ts b/packages/codev/src/agent-farm/__tests__/spec-1273-interrupt.test.ts new file mode 100644 index 000000000..805cdde9e --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-1273-interrupt.test.ts @@ -0,0 +1,222 @@ +/** + * Spec 1273 Phase 1 — `afx interrupt` and the ESC delivery path. + * + * The behaviour under test is a *verified production recovery*, not a design + * preference: on 2026-07-27 a builder wedged mid-turn for 45+ minutes resumed + * within two minutes of receiving an ESC keystroke. These tests pin the pieces + * that recovery depends on, several of which are otherwise implicit: + * + * - the exact byte sequence (ESC, then Enter by default — the Enter is what + * lets messages queued during the wedge process); + * - that a lone `\x1b` survives `handleSend`'s `trim()`/non-empty guard, an + * accidental invariant the manual recipe has always relied on; + * - that ESC delivery is never deferred by the send buffer — an interrupt that + * can be delayed because someone recently typed is not an interrupt. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + writeEscapeToSession, + ESC, + ESCAPE_ENTER_DELAY_MS, +} from '../servers/message-write.js'; +import type { PtySession } from '../../terminal/pty-session.js'; + +// ============================================================================ +// writeEscapeToSession — the byte sequence +// ============================================================================ + +function makeSession(): PtySession & { writeCalls: string[] } { + const writeCalls: string[] = []; + return { + write: vi.fn((data: string) => writeCalls.push(data)), + writeCalls, + } as unknown as PtySession & { writeCalls: string[] }; +} + +describe('writeEscapeToSession (Spec 1273)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('writes ESC immediately, then Enter after the enter delay', () => { + const session = makeSession(); + + const endTime = writeEscapeToSession(session, false); + + // ESC must not be delayed — the builder is wedged *now*. + expect(session.writeCalls).toEqual([ESC]); + + vi.advanceTimersByTime(ESCAPE_ENTER_DELAY_MS); + expect(session.writeCalls).toEqual([ESC, '\r']); + expect(endTime).toBe(ESCAPE_ENTER_DELAY_MS); + }); + + it('writes ESC alone when noEnter is set', () => { + const session = makeSession(); + + writeEscapeToSession(session, true); + + vi.advanceTimersByTime(1000); + expect(session.writeCalls).toEqual([ESC]); + }); + + it('sends the exact byte the verified manual recovery sends', () => { + // `afx send <builder> --raw "$(printf '\x1b')"` — the command form must not + // drift to a different control byte (e.g. Ctrl+C, which is a harder signal + // and is NOT what unwedged the builder). + expect(ESC).toBe('\x1b'); + expect(ESC).not.toBe('\x03'); + }); +}); + +// ============================================================================ +// The trim invariant the ESC recovery silently depends on +// ============================================================================ + +describe('ESC survives the send route input guard (Spec 1273)', () => { + it('a lone ESC is not trimmed to empty', () => { + // handleSend does `typeof body.message === 'string' ? body.message.trim() : ''` + // and rejects the result when falsy. JS trim() strips WhiteSpace and + // LineTerminator; ESC (U+001B) is neither. The whole ESC recovery — manual + // and command form alike — rests on that. If a future change normalises or + // strips control characters from message bodies, this fails loudly here + // rather than silently breaking the only mid-turn recovery we have. + expect('\x1b'.trim()).toBe('\x1b'); + expect('\x1b'.trim().length).toBe(1); + expect(Boolean('\x1b'.trim())).toBe(true); + }); + + it('whitespace-only messages still trim to empty (guard still works)', () => { + // Guards against "fix" attempts that would make the guard permissive. + expect(' \n\t '.trim()).toBe(''); + }); +}); + +// ============================================================================ +// The interrupt command +// ============================================================================ + +const { mockSendMessage, mockIsRunning, mockDetectWorkspaceRoot, mockDetectCurrentBuilderId, mockFatal } = + vi.hoisted(() => ({ + mockSendMessage: vi.fn(), + mockIsRunning: vi.fn(), + mockDetectWorkspaceRoot: vi.fn(), + mockDetectCurrentBuilderId: vi.fn(), + mockFatal: vi.fn((msg: string) => { + throw new Error(`FATAL: ${msg}`); + }), + })); + +vi.mock('../lib/tower-client.js', () => ({ + TowerClient: class { + isRunning = mockIsRunning; + sendMessage = mockSendMessage; + }, +})); + +vi.mock('../commands/send.js', () => ({ + detectWorkspaceRoot: mockDetectWorkspaceRoot, + detectCurrentBuilderId: mockDetectCurrentBuilderId, +})); + +vi.mock('../utils/logger.js', () => ({ + logger: { header: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), warn: vi.fn(), kv: vi.fn() }, + fatal: mockFatal, +})); + +describe('afx interrupt (Spec 1273)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning.mockResolvedValue(true); + mockSendMessage.mockResolvedValue({ ok: true, resolvedTo: 'builder-aspir-1273' }); + mockDetectWorkspaceRoot.mockReturnValue('/tmp/ws'); + mockDetectCurrentBuilderId.mockReturnValue(null); + }); + + it('sends the ESC byte with escape:true', async () => { + const { interrupt } = await import('../commands/interrupt.js'); + + await interrupt({ builder: '1273' }); + + expect(mockSendMessage).toHaveBeenCalledWith( + '1273', + '\x1b', + expect.objectContaining({ escape: true }), + ); + }); + + it('does not set the Ctrl+C interrupt flag (ESC is a different signal)', async () => { + const { interrupt } = await import('../commands/interrupt.js'); + + await interrupt({ builder: '1273' }); + + const opts = mockSendMessage.mock.calls[0][2]; + expect(opts.interrupt).toBeUndefined(); + expect(opts.raw).toBeUndefined(); + }); + + it('forwards noEnter when --no-enter is passed', async () => { + const { interrupt } = await import('../commands/interrupt.js'); + + await interrupt({ builder: '1273', noEnter: true }); + + expect(mockSendMessage).toHaveBeenCalledWith( + '1273', + '\x1b', + expect.objectContaining({ escape: true, noEnter: true }), + ); + }); + + it('passes the target through verbatim so Tower resolves it (no second resolver)', async () => { + const { interrupt } = await import('../commands/interrupt.js'); + + await interrupt({ builder: 'builder-aspir-1273' }); + + expect(mockSendMessage.mock.calls[0][0]).toBe('builder-aspir-1273'); + }); + + it('sends as the current builder id when run from inside a worktree', async () => { + mockDetectCurrentBuilderId.mockReturnValue('builder-spir-999'); + const { interrupt } = await import('../commands/interrupt.js'); + + await interrupt({ builder: '1273' }); + + expect(mockSendMessage).toHaveBeenCalledWith( + '1273', + '\x1b', + expect.objectContaining({ from: 'builder-spir-999' }), + ); + }); + + it('aborts when no builder is given', async () => { + const { interrupt } = await import('../commands/interrupt.js'); + + await expect(interrupt({})).rejects.toThrow(/FATAL/); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it('aborts when Tower is not running', async () => { + mockIsRunning.mockResolvedValue(false); + const { interrupt } = await import('../commands/interrupt.js'); + + await expect(interrupt({ builder: '1273' })).rejects.toThrow(/Tower is not running/); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it('aborts loudly when the send fails rather than reporting success', async () => { + mockSendMessage.mockResolvedValue({ ok: false, error: 'TERMINAL_NOT_WRITABLE' }); + const { interrupt } = await import('../commands/interrupt.js'); + + await expect(interrupt({ builder: '1273' })).rejects.toThrow(/TERMINAL_NOT_WRITABLE/); + }); + + it('aborts when the builder identity cannot be verified (issue #1094)', async () => { + mockDetectCurrentBuilderId.mockImplementation(() => { + throw new Error('Cannot resolve canonical builder id'); + }); + const { interrupt } = await import('../commands/interrupt.js'); + + await expect(interrupt({ builder: '1273' })).rejects.toThrow(/Cannot resolve canonical builder id/); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-1273-reset-command.test.ts b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-command.test.ts new file mode 100644 index 000000000..81fca053b --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-command.test.ts @@ -0,0 +1,336 @@ +/** + * `afx reset` command surface — Spec 1273, phase 6. + * + * The orchestrator tests prove the state machine's ordering. These prove the + * WRAPPER: that the right things are bound to the right ports and the target is + * resolved the way every other builder-addressed command resolves it. + * + * That split matters because the two bugs this file exists to catch were both + * invisible from the orchestrator's side. The orchestrator cannot tell whether + * `sendRaw` was wired to Tower's `raw` or its `escape` route, and it cannot tell + * whether the builder was looked up by exact id or by the shared resolver. Both + * were wrong at some point in this phase, and both would have shipped a command + * that fails in a way the state machine's tests call success. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { + mockSendMessage, + mockIsRunning, + mockGetTerminal, + mockGetTerminalOutput, + mockDetectWorkspaceRoot, + mockDetectCurrentBuilderId, + mockFindBuilderById, + mockFatal, + mockRunReset, +} = vi.hoisted(() => ({ + mockSendMessage: vi.fn(), + mockIsRunning: vi.fn(), + mockGetTerminal: vi.fn(), + mockGetTerminalOutput: vi.fn(), + mockDetectWorkspaceRoot: vi.fn(), + mockDetectCurrentBuilderId: vi.fn(), + mockFindBuilderById: vi.fn(), + mockFatal: vi.fn((msg: string) => { + throw new Error(`FATAL: ${msg}`); + }), + mockRunReset: vi.fn(), +})); + +vi.mock('../lib/tower-client.js', () => ({ + TowerClient: class { + isRunning = mockIsRunning; + sendMessage = mockSendMessage; + getTerminal = mockGetTerminal; + getTerminalOutput = mockGetTerminalOutput; + }, +})); + +vi.mock('../commands/send.js', () => ({ + detectWorkspaceRoot: mockDetectWorkspaceRoot, + detectCurrentBuilderId: mockDetectCurrentBuilderId, +})); + +vi.mock('../lib/builder-lookup.js', () => ({ + findBuilderById: mockFindBuilderById, +})); + +vi.mock('../utils/logger.js', () => ({ + logger: { header: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), warn: vi.fn(), kv: vi.fn() }, + fatal: mockFatal, +})); + +vi.mock('../utils/index.js', () => ({ + getConfig: () => ({ workspaceRoot: '/tmp/ws', codevDir: '/tmp/ws/codev' }), +})); + +vi.mock('../../lib/config.js', () => ({ + loadConfig: () => ({ harness: undefined }), +})); + +vi.mock('../../lib/forge.js', () => ({ loadForgeConfig: () => null })); +vi.mock('../../lib/github.js', () => ({ fetchIssue: async () => null })); + +vi.mock('../commands/spawn-roles.js', () => ({ + buildPromptFromTemplate: () => '# prompt', + buildResumeNotice: (id: string) => `resume ${id}`, +})); + +vi.mock('../commands/reset/context.js', () => ({ + resolveBuilderContext: (opts: Record<string, unknown>) => ({ + builderId: opts.builderId, + worktree: opts.worktree, + branch: opts.branch, + protocol: 'aspir', + mode: 'strict', + harnessName: 'claude', + harness: { supportsContextReset: true }, + porch: null, + specName: null, + specPath: null, + planPath: null, + issueNumber: opts.issueNumber, + taskText: opts.taskText, + }), +})); + +vi.mock('../commands/reset/index.js', async importOriginal => { + const actual = await importOriginal<typeof import('../commands/reset/index.js')>(); + return { ...actual, runReset: mockRunReset }; +}); + +const BUILDER = { + id: 'aspir-1273', + name: 'aspir-1273', + worktree: '/tmp/ws/.builders/aspir-1273', + branch: 'builder/aspir-1273', + terminalId: 'term-1', + issueNumber: 1273, + taskText: undefined, +}; + +describe('afx reset — command surface (Spec 1273)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning.mockResolvedValue(true); + mockDetectWorkspaceRoot.mockReturnValue('/tmp/ws'); + mockDetectCurrentBuilderId.mockReturnValue(null); + mockFindBuilderById.mockReturnValue(BUILDER); + mockGetTerminal.mockResolvedValue({ id: 'term-1', status: 'running', lastDataAt: 0 }); + mockGetTerminalOutput.mockResolvedValue({ lines: [], total: 0, hasMore: false }); + mockSendMessage.mockResolvedValue({ ok: true, resolvedTo: 'aspir-1273' }); + mockRunReset.mockResolvedValue({ + outcome: 'completed', + steps: [], + nonce: 'abc123abc123', + statePath: '/tmp/ws/.builders/aspir-1273/.builder-state.md', + reorientPath: '/tmp/ws/.builders/aspir-1273/.builder-reorient.md', + saveRequest: 'save request text', + stateBytes: 5000, + }); + }); + + // ========================================================================== + // Addressing parity + // ========================================================================== + + it('resolves the target through the shared resolver, not an exact-id lookup', async () => { + // `getBuilder` matches the id EXACTLY. Using it meant `afx reset 1273` + // failed against a builder registered as `aspir-1273` while `afx send 1273` + // reached it fine — a command the architect cannot address the way they + // already type addresses is one that gets typed wrong under pressure. + const { reset } = await import('../commands/reset.js'); + + await reset({ builder: '1273' }); + + expect(mockFindBuilderById).toHaveBeenCalledWith('1273'); + }); + + it('aborts when the target cannot be resolved or is ambiguous', async () => { + mockFindBuilderById.mockReturnValue(null); + const { reset } = await import('../commands/reset.js'); + + await expect(reset({ builder: 'nope' })).rejects.toThrow(/FATAL/); + expect(mockRunReset).not.toHaveBeenCalled(); + }); + + it('refuses a registry row missing the worktree or branch', async () => { + mockFindBuilderById.mockReturnValue({ ...BUILDER, worktree: '' }); + const { reset } = await import('../commands/reset.js'); + + await expect(reset({ builder: '1273' })).rejects.toThrow(/incomplete registry row/); + expect(mockRunReset).not.toHaveBeenCalled(); + }); + + // ========================================================================== + // Port bindings — the channel each action actually travels down + // ========================================================================== + + it('binds /clear to Tower\'s raw channel, and ESC to its escape channel', async () => { + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273' }); + + const terminal = mockRunReset.mock.calls[0][0].terminal; + + await terminal.sendRaw('/clear'); + const rawCall = mockSendMessage.mock.calls.at(-1)!; + expect(rawCall[1]).toBe('/clear'); + expect(rawCall[2]).toMatchObject({ raw: true }); + // The decisive assertion: NOT escape. Tower's escape route writes a + // hardcoded ESC and discards the body, so `escape: true` here would turn + // the clear into an interrupt while every signal still read as success. + expect(rawCall[2].escape).toBeUndefined(); + + await terminal.sendEscape(); + const escCall = mockSendMessage.mock.calls.at(-1)!; + expect(escCall[1]).toBe('\x1b'); + expect(escCall[2]).toMatchObject({ escape: true }); + expect(escCall[2].raw).toBeUndefined(); + }); + + it('sends the save request and re-orientation as formatted messages', async () => { + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273' }); + + const terminal = mockRunReset.mock.calls[0][0].terminal; + await terminal.sendMessage('save your state'); + + const call = mockSendMessage.mock.calls.at(-1)!; + expect(call[2].raw).toBeUndefined(); + expect(call[2].escape).toBeUndefined(); + }); + + it('reports lastDataAt as undefined rather than defaulting it to zero', async () => { + // An older Tower omits the field. The orchestrator treats undefined as + // "unobservable" and refuses to clear; collapsing it to 0 at this boundary + // would defeat that check before the orchestrator ever saw it. + mockGetTerminal.mockResolvedValue({ id: 'term-1', status: 'running' }); + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273' }); + + const terminal = mockRunReset.mock.calls[0][0].terminal; + await expect(terminal.observe()).resolves.toEqual({ exists: true, lastDataAt: undefined }); + }); + + it('binds readOutput so the clear confirmation can actually succeed', async () => { + // Left unbound, `confirmClear` returns false on every production run and the + // report says "clear-unconfirmed" forever — a check that looks attempted and + // can only ever pass in tests. + mockGetTerminalOutput.mockResolvedValue({ + lines: ['> /clear', 'context cleared'], + total: 2, + hasMore: false, + }); + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273' }); + + const terminal = mockRunReset.mock.calls[0][0].terminal; + expect(terminal.readOutput).toBeDefined(); + const out = await terminal.readOutput(); + expect(out.lines.join('\n')).toContain('context cleared'); + // `total` must come through — the confirmation window depends on it. + expect(out.total).toBe(2); + }); + + it('returns null recent output rather than throwing when Tower cannot serve it', async () => { + // Confirmation is advisory: an older Tower or a 404 must degrade to + // "unconfirmed", never fail the reset that already succeeded. + mockGetTerminalOutput.mockResolvedValue(null); + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273' }); + + const terminal = mockRunReset.mock.calls[0][0].terminal; + await expect(terminal.readOutput()).resolves.toBeNull(); + }); + + it('reports a non-running terminal as absent', async () => { + mockGetTerminal.mockResolvedValue({ id: 'term-1', status: 'exited' }); + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273' }); + + const terminal = mockRunReset.mock.calls[0][0].terminal; + await expect(terminal.observe()).resolves.toEqual({ exists: false }); + }); + + // ========================================================================== + // Options plumbing + // ========================================================================== + + it('converts --timeout from seconds to milliseconds', async () => { + // The flag is documented in seconds; the orchestrator takes ms. An + // unconverted 300 would make the receipt wait 0.3s instead of 5 minutes and + // abort against every real builder. + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273', timeout: 300 }); + + expect(mockRunReset.mock.calls[0][0].receiptTimeoutMs).toBe(300_000); + }); + + it('passes --note through as the addendum', async () => { + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273', note: 'Ignore the stale PR comment.' }); + + expect(mockRunReset.mock.calls[0][0].addendum).toBe('Ignore the stale PR comment.'); + }); + + it('forwards --dry-run and --interrupt-first', async () => { + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273', dryRun: true, interruptFirst: true }); + + const opts = mockRunReset.mock.calls[0][0]; + expect(opts.dryRun).toBe(true); + expect(opts.interruptFirst).toBe(true); + }); + + // ========================================================================== + // Numeric flag validation — the wrapper must not pass a gate-disabling value + // ========================================================================== + + it('rejects a gate-disabling numeric option before runReset is called', async () => { + // The CLI layer validates first, but the wrapper is also reachable + // programmatically, and `reset()` must not forward a value that would + // silently switch off R2 or R4. The orchestrator's own guard is what + // enforces it; this asserts the wrapper does not swallow that refusal and + // report success anyway. + mockRunReset.mockRejectedValue( + Object.assign(new Error('Invalid quietWindowMs: -1.'), { name: 'ResetPreflightError' }), + ); + const { reset } = await import('../commands/reset.js'); + + await expect(reset({ builder: '1273', quietWindow: -1 })).rejects.toThrow(/FATAL/); + }); + + it('omits an unset numeric option rather than passing NaN or zero', async () => { + // `options.timeout ? ... : undefined` on an absent flag must yield + // undefined so the orchestrator's own default applies. Passing 0 or NaN + // here would disable the gate the default exists to enforce. + const { reset } = await import('../commands/reset.js'); + await reset({ builder: '1273' }); + + const opts = mockRunReset.mock.calls[0][0]; + expect(opts.receiptTimeoutMs).toBeUndefined(); + expect(opts.minBytes).toBeUndefined(); + expect(opts.quietWindowMs ?? opts.quietWindow).toBeUndefined(); + }); + + it('exits non-zero when the run aborts, so a script cannot read it as success', async () => { + mockRunReset.mockResolvedValue({ + outcome: 'aborted', + steps: [], + abortReason: 'Save-state receipt not verified.', + nonce: 'abc123abc123', + statePath: '/x/.builder-state.md', + reorientPath: '/x/.builder-reorient.md', + saveRequest: 'save request text', + }); + const previous = process.exitCode; + const { reset } = await import('../commands/reset.js'); + + await reset({ builder: '1273' }); + + expect(process.exitCode).toBe(1); + process.exitCode = previous; + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-1273-reset-context.test.ts b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-context.test.ts new file mode 100644 index 000000000..d71250651 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-context.test.ts @@ -0,0 +1,450 @@ +/** + * Spec 1273 Phase 4 — builder context resolution. + * + * This phase exists because the plan CMAP caught the first draft assuming the + * builder registry carried protocol, mode and harness. It does not, and the most + * important test here is the one proving resolution works when + * `builders.protocol_name` is NULL — the state every SPIR/ASPIR lane is actually + * in, and the case a registry-based implementation would have failed on while + * looking correct in review. + * + * Every chain must end in a loud abort rather than a default: a guessed protocol + * or mode produces a plausible-looking re-orientation that quietly reframes the + * builder, which is precisely the drift R3 exists to prevent. + */ + +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { + resolveBuilderContext, + readPorchContext, + protocolFromStatus, + modeFromBuilderPrompt, + harnessFromLaunchScript, + ContextResolutionError, + type ContextFsPort, +} from '../commands/reset/context.js'; + +// ============================================================================ +// Fixtures +// ============================================================================ + +const WORKTREE = '/ws/.builders/aspir-1273'; +const BRANCH = 'builder/aspir-1273'; +const BUILDER_ID = 'builder-aspir-1273'; + +const STATUS_YAML = `id: '1273' +title: builder-context-reset-should-b +protocol: aspir +phase: implement +plan_phases: [] +current_plan_phase: phase_4 +iteration: 1 +`; + +const BUILDER_PROMPT = `You are a Builder. Read codev/roles/builder.md for your full role definition. + +# ASPIR Builder (strict mode) + +You are implementing the feature specified in codev/specs/1273-x.md. + +## Mode: STRICT +You are running in STRICT mode. +`; + +const LAUNCH_SCRIPT = `#!/bin/bash +cd "${WORKTREE}" +while true; do + claude --dangerously-skip-permissions --append-system-prompt "$(cat '${WORKTREE}/.builder-role.md')" "$(cat '${WORKTREE}/.builder-prompt.txt')" + status=$? +done +`; + +/** In-memory worktree. `dirs` maps a directory to its immediate subdirectories. */ +function makeFs(files: Record<string, string>, dirs: Record<string, string[]> = {}): ContextFsPort { + return { + exists: (p) => p in files || p in dirs || Object.keys(files).some(f => f.startsWith(`${p}/`)), + read: (p) => (p in files ? files[p] : null), + listDirs: (p) => (p in dirs ? dirs[p] : null), + }; +} + +function fullWorktree(overrides: Record<string, string> = {}): ContextFsPort { + const projectDir = '1273-builder-context-reset-should-b'; + return makeFs( + { + [join(WORKTREE, 'codev', 'projects', projectDir, 'status.yaml')]: STATUS_YAML, + [join(WORKTREE, '.builder-prompt.txt')]: BUILDER_PROMPT, + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT, + ...overrides, + }, + { [join(WORKTREE, 'codev', 'projects')]: [projectDir] }, + ); +} + +const BASE = { builderId: BUILDER_ID, worktree: WORKTREE, branch: BRANCH }; + +// ============================================================================ +// Protocol +// ============================================================================ + +describe('protocol resolution (Spec 1273)', () => { + it('resolves from porch status.yaml when the lane is porch-driven', () => { + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE }); + expect(ctx.protocol).toBe('aspir'); + expect(ctx.protocolSource).toBe('status.yaml'); + }); + + it('resolves correctly for a spec-type builder whose registry protocol_name is NULL', () => { + // THE case this phase exists for. Nothing in resolution touches the registry, + // so a NULL protocol_name — the state of every SPIR/ASPIR lane — is a non-event. + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE }); + expect(ctx.protocol).toBe('aspir'); + }); + + it('falls back to the canonical builder id when there is no porch project', () => { + const fs = makeFs({ + [join(WORKTREE, '.builder-prompt.txt')]: BUILDER_PROMPT, + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT, + }); + const ctx = resolveBuilderContext({ fs, ...BASE }); + expect(ctx.protocol).toBe('aspir'); + expect(ctx.protocolSource).toBe('builder-id'); + }); + + it('aborts when neither source names a protocol', () => { + const fs = makeFs({ + [join(WORKTREE, '.builder-prompt.txt')]: BUILDER_PROMPT, + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT, + }); + expect(() => + resolveBuilderContext({ fs, ...BASE, builderId: 'some-legacy-name' }), + ).toThrow(ContextResolutionError); + }); + + it('prefers status.yaml over the builder id when they disagree', () => { + // status.yaml is what porch is actually running. + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE, builderId: 'builder-spir-1273' }); + expect(ctx.protocol).toBe('aspir'); + }); + + it('protocolFromStatus returns null when there are no project dirs', () => { + expect(protocolFromStatus(makeFs({}), WORKTREE)).toBeNull(); + }); +}); + +// ============================================================================ +// Porch context +// ============================================================================ + +describe('porch context (Spec 1273)', () => { + it('reads phase and current plan phase', () => { + const porch = readPorchContext(fullWorktree(), WORKTREE); + expect(porch?.phase).toBe('implement'); + expect(porch?.currentPlanPhase).toBe('phase_4'); + expect(porch?.projectId).toBe('1273'); + }); + + it('treats a literal null current_plan_phase as absent', () => { + const fs = fullWorktree({ + [join(WORKTREE, 'codev', 'projects', '1273-builder-context-reset-should-b', 'status.yaml')]: + STATUS_YAML.replace('current_plan_phase: phase_4', 'current_plan_phase: null'), + }); + expect(readPorchContext(fs, WORKTREE)?.currentPlanPhase).toBeNull(); + }); + + it('returns null for a non-porch lane instead of throwing', () => { + // A task or shell builder is a legitimate reset target — it just gets no + // porch re-entry block. This is a branch, not a gate failure. + const fs = makeFs({ + [join(WORKTREE, '.builder-prompt.txt')]: BUILDER_PROMPT, + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT, + }); + expect(readPorchContext(fs, WORKTREE)).toBeNull(); + + const ctx = resolveBuilderContext({ fs, ...BASE }); + expect(ctx.porch).toBeNull(); + }); +}); + +// ============================================================================ +// Mode +// ============================================================================ + +describe('mode resolution (Spec 1273)', () => { + it('reads the literal "## Mode: STRICT" line the builder was given', () => { + expect(modeFromBuilderPrompt(fullWorktree(), WORKTREE)).toBe('strict'); + }); + + it('reads SOFT too', () => { + const fs = fullWorktree({ + [join(WORKTREE, '.builder-prompt.txt')]: BUILDER_PROMPT.replace('## Mode: STRICT', '## Mode: SOFT'), + }); + expect(modeFromBuilderPrompt(fs, WORKTREE)).toBe('soft'); + }); + + it('lets --mode win over the worktree', () => { + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE, modeOverride: 'soft' }); + expect(ctx.mode).toBe('soft'); + expect(ctx.modeSource).toBe('flag'); + }); + + it('aborts naming --mode when the prompt file has no mode line', () => { + // Mode is persisted NOWHERE else — resolveMode cannot recover a spawn-time + // --soft after the fact — so guessing here would silently reframe the builder. + const fs = fullWorktree({ + [join(WORKTREE, '.builder-prompt.txt')]: 'You are a Builder.\n', + }); + expect(() => resolveBuilderContext({ fs, ...BASE })).toThrow(/--mode/); + }); + + it('aborts when the prompt file is missing entirely', () => { + const fs = makeFs( + { + [join(WORKTREE, 'codev', 'projects', '1273-x', 'status.yaml')]: STATUS_YAML, + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT, + }, + { [join(WORKTREE, 'codev', 'projects')]: ['1273-x'] }, + ); + expect(() => resolveBuilderContext({ fs, ...BASE })).toThrow(ContextResolutionError); + }); +}); + +// ============================================================================ +// Harness +// ============================================================================ + +describe('harness resolution (Spec 1273)', () => { + it('identifies the harness from the launch script', () => { + expect(harnessFromLaunchScript(fullWorktree(), WORKTREE)).toBe('claude'); + }); + + it('ignores the cd and export lines when scanning', () => { + expect(harnessFromLaunchScript(fullWorktree(), WORKTREE)).not.toBe('codex'); + }); + + it('resolves a claude builder and reports the harness', () => { + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE }); + expect(ctx.harnessName).toBe('claude'); + expect(ctx.harness.supportsContextReset).toBe(true); + }); + + it('aborts loudly for a harness without in-session reset, naming it', () => { + const fs = fullWorktree({ + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT.replace('claude ', 'codex '), + }); + expect(() => resolveBuilderContext({ fs, ...BASE })).toThrow(/codex/); + expect(() => resolveBuilderContext({ fs, ...BASE })).toThrow(/no in-session context reset/); + }); + + it('aborts when the launch script names no recognisable harness', () => { + const fs = fullWorktree({ + [join(WORKTREE, '.builder-start.sh')]: '#!/bin/bash\ncd /tmp\nwhile true; do\n ./some-agent\ndone\n', + }); + expect(() => resolveBuilderContext({ fs, ...BASE })).toThrow(/harness/); + }); + + it('aborts when the launch script is missing', () => { + const fs = makeFs( + { + [join(WORKTREE, 'codev', 'projects', '1273-x', 'status.yaml')]: STATUS_YAML, + [join(WORKTREE, '.builder-prompt.txt')]: BUILDER_PROMPT, + }, + { [join(WORKTREE, 'codev', 'projects')]: ['1273-x'] }, + ); + expect(() => resolveBuilderContext({ fs, ...BASE })).toThrow(ContextResolutionError); + }); +}); + +// ============================================================================ +// Whole-context behaviour +// ============================================================================ + +describe('resolveBuilderContext (Spec 1273)', () => { + it('returns every field R3 requires', () => { + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE, issueNumber: '1273' }); + + expect(ctx.builderId).toBe(BUILDER_ID); + expect(ctx.worktree).toBe(WORKTREE); + expect(ctx.branch).toBe(BRANCH); + expect(ctx.protocol).toBe('aspir'); + expect(ctx.mode).toBe('strict'); + expect(ctx.harnessName).toBe('claude'); + expect(ctx.porch?.phase).toBe('implement'); + expect(ctx.issueNumber).toBe('1273'); + }); + + it('reports where protocol and mode came from, so the report is auditable', () => { + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE }); + expect(ctx.protocolSource).toBe('status.yaml'); + expect(ctx.modeSource).toBe('builder-prompt'); + }); + + it('aborts when the worktree does not exist', () => { + expect(() => resolveBuilderContext({ fs: makeFs({}), ...BASE })).toThrow(/Worktree not found/); + }); + + it('resolves the artifact identity phase 5 needs to rebuild the spawn template context', () => { + const projectDir = '1273-builder-context-reset-should-b'; + const fs = fullWorktree({ + [join(WORKTREE, 'codev', 'specs', `${projectDir}.md`)]: '# spec', + [join(WORKTREE, 'codev', 'plans', `${projectDir}.md`)]: '# plan', + }); + const ctx = resolveBuilderContext({ fs, ...BASE }); + + expect(ctx.specName).toBe(projectDir); + expect(ctx.specPath).toBe(join('codev', 'specs', `${projectDir}.md`)); + expect(ctx.planPath).toBe(join('codev', 'plans', `${projectDir}.md`)); + }); + + it('reports a missing plan as null rather than pointing at a file that is not there', () => { + // A pointer to a nonexistent plan would send a freshly-reset builder — one + // with no memory to cross-check against — chasing a ghost. + const projectDir = '1273-builder-context-reset-should-b'; + const fs = fullWorktree({ + [join(WORKTREE, 'codev', 'specs', `${projectDir}.md`)]: '# spec', + }); + const ctx = resolveBuilderContext({ fs, ...BASE }); + + expect(ctx.specPath).toBe(join('codev', 'specs', `${projectDir}.md`)); + expect(ctx.planPath).toBeNull(); + }); + + it('has no artifact identity on a non-porch lane', () => { + const fs = makeFs({ + [join(WORKTREE, '.builder-prompt.txt')]: BUILDER_PROMPT, + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT, + }); + const ctx = resolveBuilderContext({ fs, ...BASE }); + + expect(ctx.specName).toBeNull(); + expect(ctx.specPath).toBeNull(); + expect(ctx.planPath).toBeNull(); + }); + + it('falls back to the porch project id for the issue number', () => { + // Issue-driven protocols name the porch project after the issue, so this is + // the right value when the registry row carries none. + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE }); + expect(ctx.issueNumber).toBe('1273'); + }); + + it('prefers an explicitly supplied issue number over the project id', () => { + const ctx = resolveBuilderContext({ fs: fullWorktree(), ...BASE, issueNumber: '999' }); + expect(ctx.issueNumber).toBe('999'); + }); +}); + +// ============================================================================ +// Custom harnesses +// ============================================================================ + +describe('custom harness resolution (Spec 1273)', () => { + const custom = { + 'acme-agent': { + command: 'acme-agent', + roleArgs: ['--system', '${ROLE_FILE}'], + roleScriptFragment: "--system '${ROLE_FILE}'", + }, + } as any; + + function customWorktree() { + return fullWorktree({ + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT.replace('claude ', 'acme-agent '), + }); + } + + it('recognises a project-defined harness from the launch script', () => { + expect(harnessFromLaunchScript(customWorktree(), WORKTREE, custom)).toBe('acme-agent'); + }); + + it('maps it to a real provider and refuses on the accurate ground', () => { + // Without custom-harness support the refusal would be "unrecognisable + // launch command", sending the project to debug its config. The accurate + // refusal is that this harness cannot clear context in-session. + expect(() => resolveBuilderContext({ fs: customWorktree(), ...BASE, customHarnesses: custom })) + .toThrow(/no in-session context reset/); + expect(() => resolveBuilderContext({ fs: customWorktree(), ...BASE, customHarnesses: custom })) + .toThrow(/acme-agent/); + }); + + it('still reports an unknown harness as unrecognisable when no config defines it', () => { + expect(() => resolveBuilderContext({ fs: customWorktree(), ...BASE })) + .toThrow(/Cannot determine the harness/); + }); + + it('prefers a longer custom name over a builtin substring match', () => { + const shadowing = { 'claude-experimental': { command: 'claude-experimental', roleArgs: [], roleScriptFragment: '' } } as any; + const fs = fullWorktree({ + [join(WORKTREE, '.builder-start.sh')]: LAUNCH_SCRIPT.replace('claude ', 'claude-experimental '), + }); + expect(harnessFromLaunchScript(fs, WORKTREE, shadowing)).toBe('claude-experimental'); + }); +}); + +// ============================================================================ +// Launch-script scanning precision +// ============================================================================ + +describe('harness detection matches command position only (Spec 1273)', () => { + it('does not false-positive on a harness name inside a conditional', () => { + // Naming the wrong harness is not a harmless misread: it either refuses a + // resettable builder, or — worse — approves typing /clear into one that + // cannot reset. So detection matches the command, not the whole line. + const script = [ + '#!/bin/bash', + `cd "${WORKTREE}"`, + 'if [ "$HARNESS" = "codex" ]; then', + ' echo "not this one"', + 'fi', + 'while true; do', + ' claude --append-system-prompt "$(cat role.md)"', + 'done', + ].join('\n'); + const fs = fullWorktree({ [join(WORKTREE, '.builder-start.sh')]: script }); + + expect(harnessFromLaunchScript(fs, WORKTREE)).toBe('claude'); + }); + + it('does not treat a variable assignment mentioning a harness as an invocation', () => { + const script = `#!/bin/bash\nAGENT_KIND=codex\nwhile true; do\n claude --foo\ndone\n`; + const fs = fullWorktree({ [join(WORKTREE, '.builder-start.sh')]: script }); + + expect(harnessFromLaunchScript(fs, WORKTREE)).toBe('claude'); + }); + + it('resolves an absolute path to its basename', () => { + const script = `#!/bin/bash\nwhile true; do\n /usr/local/bin/claude --resume abc\ndone\n`; + const fs = fullWorktree({ [join(WORKTREE, '.builder-start.sh')]: script }); + + expect(harnessFromLaunchScript(fs, WORKTREE)).toBe('claude'); + }); + + it('sees through an env-var prefix and an exec', () => { + const script = `#!/bin/bash\nwhile true; do\n exec FOO=1 claude --resume abc\ndone\n`; + const fs = fullWorktree({ [join(WORKTREE, '.builder-start.sh')]: script }); + + expect(harnessFromLaunchScript(fs, WORKTREE)).toBe('claude'); + }); + + it('ignores comments that mention a harness', () => { + const script = `#!/bin/bash\n# previously ran under codex\nwhile true; do\n claude --foo\ndone\n`; + const fs = fullWorktree({ [join(WORKTREE, '.builder-start.sh')]: script }); + + expect(harnessFromLaunchScript(fs, WORKTREE)).toBe('claude'); + }); + + it('is read-only — no writes anywhere', () => { + const calls: string[] = []; + const base = fullWorktree(); + const fs: ContextFsPort = { + exists: (p) => { calls.push(`exists:${p}`); return base.exists(p); }, + read: (p) => { calls.push(`read:${p}`); return base.read(p); }, + listDirs: (p) => { calls.push(`listDirs:${p}`); return base.listDirs(p); }, + }; + + resolveBuilderContext({ fs, ...BASE }); + + expect(calls.every(c => /^(exists|read|listDirs):/.test(c))).toBe(true); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-1273-reset-orchestrator.test.ts b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-orchestrator.test.ts new file mode 100644 index 000000000..76ee30605 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-orchestrator.test.ts @@ -0,0 +1,895 @@ +/** + * Reset orchestrator — invariant tests (Spec 1273, phase 6). + * + * The reason these tests are shaped the way they are: + * + * Reset's safety properties are ORDERING properties, and ordering is the one + * thing a "does it return the right value" test cannot see. A run that clears a + * builder BEFORE saving its state and a run that clears it after both end with + * `outcome: 'completed'`. Only the sequence distinguishes them. + * + * So almost every assertion here is over the **step log**, and the important + * ones are assertions of ABSENCE — that `clear` never appears in an aborted + * run, that `escalate-esc` never precedes `receipt-accepted`. A test that only + * checks the happy path would pass against an implementation that clears first + * and asks questions later. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + runReset, + ResetPreflightError, + formatResetReport, + type ClockPort, + type ResetFsPort, + type ResetStepName, + type TerminalPort, +} from '../commands/reset/index.js'; +import type { ResolvedBuilderContext } from '../commands/reset/context.js'; +import { STATE_FILE_NAME, REORIENT_FILE_NAME } from '../commands/reset/constants.js'; + +// ============================================================================ +// Harness +// ============================================================================ + +const WORKTREE = '/repo/.builders/aspir-1273'; +const SPAWN_PROMPT = '# Builder prompt\n\nYou are implementing the feature.'; + +function makeContext(overrides: Partial<ResolvedBuilderContext> = {}): ResolvedBuilderContext { + return { + builderId: 'aspir-1273', + worktree: WORKTREE, + branch: 'builder/aspir-1273', + protocol: 'aspir', + protocolSource: 'status.yaml', + mode: 'strict', + modeSource: 'builder-prompt', + harnessName: 'claude', + harness: { supportsContextReset: true } as ResolvedBuilderContext['harness'], + porch: { + projectId: '1273', + projectName: '1273-builder-context-reset-should-b', + protocol: 'aspir', + phase: 'implement', + currentPlanPhase: 'phase_6', + statusPath: `${WORKTREE}/codev/projects/1273-builder-context-reset-should-b/status.yaml`, + }, + specName: '1273-builder-context-reset-should-b', + specPath: 'codev/specs/1273-builder-context-reset-should-b.md', + planPath: 'codev/plans/1273-builder-context-reset-should-b.md', + issueNumber: '1273', + ...overrides, + }; +} + +/** + * A clock that advances only when someone sleeps. + * + * Deterministic and instant: a 5-minute receipt timeout costs no wall-clock, so + * the timeout paths are cheap enough to test exhaustively — which matters, + * because the timeout paths are the ones that must NOT clear. + */ +function makeClock(): ClockPort & { advance(ms: number): void } { + let t = 1_000_000; + return { + now: () => t, + sleep: async (ms: number) => { + t += ms; + }, + advance: (ms: number) => { + t += ms; + }, + }; +} + +/** + * A state file that appears after N polls, mimicking a builder that takes a + * while to reach the request and then writes. + */ +function makeFs(script: { + appearsAfterReads?: number; + content?: (nonce: string) => string; + bytes?: number; + keepGrowing?: boolean; +}): ResetFsPort & { writes: Array<{ path: string; content: string }> } { + const writes: Array<{ path: string; content: string }> = []; + let reads = 0; + const appearsAfter = script.appearsAfterReads ?? 0; + let size = script.bytes ?? 5000; + + return { + writes, + read(path: string) { + if (path.endsWith('.builder-state.md')) { + reads++; + if (reads <= appearsAfter) return null; + return script.content ? script.content(CURRENT_NONCE.value) : `nonce ${CURRENT_NONCE.value}\nstate`; + } + return null; + }, + sizeOf(path: string) { + if (path.endsWith('.builder-state.md')) { + if (reads < appearsAfter) return null; + if (script.keepGrowing) size += 100; + return size; + } + return null; + }, + write(path: string, content: string) { + writes.push({ path, content }); + }, + }; +} + +/** + * The nonce is generated inside `runReset`, so a scripted state file cannot know + * it up front. The orchestrator hands it to the builder via the save request; + * this captures it from the message the terminal port receives, which is exactly + * the channel a real builder would learn it through. + */ +const CURRENT_NONCE = { value: '' }; + +/** + * What the terminal already contains before the clear: reset's own messages. + * + * Deliberately includes the save request's "CONTEXT RESET INCOMING" header, + * because that string matches the confirmation pattern. If the window logic + * regresses, these lines leak into the check and confirmation goes true for the + * wrong reason — which is exactly the bug this models. + */ +const PRE_CLEAR_BUFFER = [ + 'CONTEXT RESET INCOMING — save your working state now.', + 'Write your complete working state to .builder-state.md', +]; + +function makeTerminal(script: { + exists?: boolean; + /** Sequence of lastDataAt offsets relative to now; `undefined` = unobservable. */ + quietness?: Array<number | undefined>; + recentOutput?: string; +} = {}): TerminalPort & { + messages: string[]; + raw: string[]; + escapes: number; +} { + const messages: string[] = []; + const raw: string[] = []; + let escapes = 0; + const quietness = script.quietness ?? []; + let observeCount = 0; + + const port = { + messages, + raw, + get escapes() { + return escapes; + }, + async observe() { + const idx = Math.min(observeCount, quietness.length - 1); + observeCount++; + if (script.exists === false) return { exists: false }; + if (quietness.length === 0) return { exists: true, lastDataAt: 0 }; + const entry = quietness[idx]; + return { exists: true, lastDataAt: entry }; + }, + async sendMessage(message: string) { + messages.push(message); + const match = message.match(/([0-9a-f]{12})/); + if (match) CURRENT_NONCE.value = match[1]; + }, + async sendRaw(text: string) { + raw.push(text); + }, + async sendEscape() { + escapes++; + }, + readOutput: script.recentOutput !== undefined + ? async () => { + // `total` grows once the clear is sent, so the fresh-window slice is + // exactly the scripted post-clear output. Before the clear it reports + // the pre-existing buffer only. + const post = script.recentOutput!.split('\n'); + return raw.includes('/clear') + ? { lines: [...PRE_CLEAR_BUFFER, ...post], total: PRE_CLEAR_BUFFER.length + post.length } + : { lines: [...PRE_CLEAR_BUFFER], total: PRE_CLEAR_BUFFER.length }; + } + : undefined, + }; + return port; +} + +const spawnPort = () => SPAWN_PROMPT; +const resumePort = (id: string) => `## RESUME SESSION\n\nRun porch next for ${id}.\nIf porch reports "not found", run porch init.`; + +/** + * Quiescence observations old enough to count as quiet. + * + * The clock starts at 1_000_000 and `quietWindowMs` defaults to 1500, so a + * `lastDataAt` of 0 is "silent since the epoch" — comfortably quiet. + */ +const QUIET: Array<number | undefined> = [0]; + +function baseOptions(overrides: Record<string, unknown> = {}) { + return { + context: makeContext(), + fs: makeFs({}), + clock: makeClock(), + terminal: makeTerminal({ quietness: QUIET }), + buildSpawnPrompt: spawnPort, + buildResumeNotice: resumePort, + ...overrides, + }; +} + +const names = (steps: Array<{ name: ResetStepName }>) => steps.map(s => s.name); + +// ============================================================================ +// Happy path +// ============================================================================ + +describe('Spec 1273 — reset orchestrator: happy path', () => { + it('runs request → receipt → quiescence → clear → re-orientation, in that order', async () => { + const result = await runReset(baseOptions() as never); + + expect(result.outcome).toBe('completed'); + expect(names(result.steps)).toEqual([ + 'resolve', + 'assemble', + 'write-reorient-file', + 'send-save-request', + 'receipt-accepted', + 'quiescent', + 'clear', + 'clear-unconfirmed', + 'send-reorientation', + ]); + }); + + it('writes the long form to the worktree before anything is sent to the builder', async () => { + const fs = makeFs({}); + const terminal = makeTerminal({ quietness: QUIET }); + const result = await runReset(baseOptions({ fs, terminal }) as never); + + expect(fs.writes).toHaveLength(1); + expect(fs.writes[0].path).toBe(`${WORKTREE}/.builder-reorient.md`); + expect(fs.writes[0].content).toContain(SPAWN_PROMPT); + expect(result.outcome).toBe('completed'); + }); + + it('delivers the inline frame as the final action', async () => { + const terminal = makeTerminal({ quietness: QUIET }); + await runReset(baseOptions({ terminal }) as never); + + // Two messages: the save request, then the re-orientation. + expect(terminal.messages).toHaveLength(2); + expect(terminal.messages[0]).toContain('.builder-state.md'); + expect(terminal.messages[1]).toContain('CONTEXT RESET'); + }); + + it('delivers /clear down the RAW channel, never the escape channel', async () => { + // This is a regression test for a bug caught in review rather than in + // production. Tower's escape route (`writeEscapeToSession`) writes a + // hardcoded ESC and DISCARDS the message body, so wiring the clear to the + // escape channel sends an interrupt instead of typing `/clear`. Every + // observable signal would still look like success: the send returns ok, the + // terminal goes quiet (an ESC ends the turn), the re-orientation arrives. + // The only thing that would not happen is the reset. + const terminal = makeTerminal({ quietness: QUIET }); + const result = await runReset(baseOptions({ terminal }) as never); + + expect(result.outcome).toBe('completed'); + expect(terminal.raw).toContain('/clear'); + expect(terminal.escapes).toBe(0); + }); + + it('does NOT count the echo of the typed /clear as confirmation', async () => { + // The PTY echoes input, so `/clear` is guaranteed to appear in recent + // output on every single run. Treating that as confirmation made the check + // self-fulfilling — it reported "clear-confirmed" whether or not anything + // was cleared. A false "confirmed" is worse than the earlier always- + // unconfirmed bug, because the architect trusts it. + const terminal = makeTerminal({ quietness: QUIET, recentOutput: '> /clear\n> ' }); + const result = await runReset(baseOptions({ terminal }) as never); + + expect(names(result.steps)).toContain('clear-unconfirmed'); + expect(names(result.steps)).not.toContain('clear-confirmed'); + }); + + it('does NOT match reset\'s own save-request text still sitting in the buffer', async () => { + // The save request opens with "CONTEXT RESET INCOMING", which matches the + // confirmation pattern. It is in the buffer on every run because reset put + // it there moments earlier. Scanning the whole buffer therefore confirmed + // the clear using reset's own words — the second false-positive in this + // check, after the echoed `/clear`. + // + // The fix is structural rather than a better regex: only output produced + // AFTER the clear is considered, so anything reset wrote is excluded by + // construction. PRE_CLEAR_BUFFER carries that exact header. + const terminal = makeTerminal({ quietness: QUIET, recentOutput: '> ' }); + const result = await runReset(baseOptions({ terminal }) as never); + + expect(names(result.steps)).toContain('clear-unconfirmed'); + expect(names(result.steps)).not.toContain('clear-confirmed'); + }); + + it('reports the clear as confirmed when the terminal echoes it', async () => { + const terminal = makeTerminal({ quietness: QUIET, recentOutput: 'context cleared' }); + const result = await runReset(baseOptions({ terminal }) as never); + + expect(names(result.steps)).toContain('clear-confirmed'); + expect(names(result.steps)).not.toContain('clear-unconfirmed'); + }); +}); + +// ============================================================================ +// R1 — never clear without a saved re-orientation +// ============================================================================ + +describe('Spec 1273 — R1: no clear without a persisted re-orientation', () => { + it('aborts before touching the builder when assembly fails', async () => { + const terminal = makeTerminal({ quietness: QUIET }); + const fs = makeFs({}); + + // A context with no branch cannot produce a complete frame (R3). + await expect( + runReset(baseOptions({ + context: makeContext({ branch: '' }), + terminal, + fs, + }) as never), + ).rejects.toThrow(ResetPreflightError); + + // The decisive assertions: nothing reached the builder, and nothing was + // written. An assembly failure must leave the builder exactly as it was. + expect(terminal.messages).toHaveLength(0); + expect(terminal.raw).toHaveLength(0); + expect(fs.writes).toHaveLength(0); + }); + + it('always orders assemble and write-reorient-file before clear', async () => { + const result = await runReset(baseOptions() as never); + const order = names(result.steps); + + expect(order.indexOf('assemble')).toBeLessThan(order.indexOf('clear')); + expect(order.indexOf('write-reorient-file')).toBeLessThan(order.indexOf('clear')); + }); +}); + +// ============================================================================ +// R2 — never clear without a verified receipt +// ============================================================================ + +describe('Spec 1273 — R2: no clear without a verified save-state receipt', () => { + const failureCases: Array<{ label: string; fs: () => ResetFsPort }> = [ + { + label: 'the file never appears', + fs: () => makeFs({ appearsAfterReads: 10_000 }), + }, + { + label: 'the file carries a stale nonce', + fs: () => makeFs({ content: () => 'nonce deadbeefcafe\nstale state from a previous reset' }), + }, + { + label: 'the file is a stub below the size floor', + fs: () => makeFs({ bytes: 120 }), + }, + { + label: 'the file is still being written', + fs: () => makeFs({ keepGrowing: true }), + }, + ]; + + for (const { label, fs } of failureCases) { + it(`aborts with NO clear when ${label}`, async () => { + const terminal = makeTerminal({ quietness: QUIET }); + const result = await runReset( + baseOptions({ fs: fs(), terminal, receiptTimeoutMs: 10_000 }) as never, + ); + + expect(result.outcome).toBe('aborted'); + expect(names(result.steps)).not.toContain('clear'); + expect(names(result.steps)).not.toContain('send-reorientation'); + expect(terminal.raw).not.toContain('/clear'); + // The abort must name the gate, not just say "failed". + expect(result.abortReason).toMatch(/receipt not verified/i); + }); + } + + it('names the specific gate so the architect can tell a stub from silence', async () => { + const stub = await runReset( + baseOptions({ fs: makeFs({ bytes: 120 }), receiptTimeoutMs: 10_000 }) as never, + ); + const silent = await runReset( + baseOptions({ fs: makeFs({ appearsAfterReads: 10_000 }), receiptTimeoutMs: 10_000 }) as never, + ); + + expect(stub.abortReason).toMatch(/stub, not a working-state save/); + expect(silent.abortReason).toMatch(/never written/); + expect(stub.abortReason).not.toEqual(silent.abortReason); + }); +}); + +// ============================================================================ +// R4 — never clear a builder mid-turn +// ============================================================================ + +describe('Spec 1273 — R4: no clear while the builder is mid-turn', () => { + it('escalates exactly once, then aborts without clearing if still busy', async () => { + const clock = makeClock(); + // Always "just emitted output" — never quiet, however long we wait. + const terminal = makeTerminal({ quietness: [clock.now()] }); + vi.spyOn(terminal, 'observe').mockImplementation(async () => ({ + exists: true, + lastDataAt: clock.now(), + })); + + const result = await runReset( + baseOptions({ + clock, + terminal, + quiesceTimeoutMs: 5_000, + quiescePostEscalationTimeoutMs: 3_000, + }) as never, + ); + + expect(result.outcome).toBe('aborted'); + expect(names(result.steps)).not.toContain('clear'); + // EXACTLY one ESC. A second escalation would be an unbounded retry against + // a builder that has already ignored one. + expect(terminal.escapes).toBe(1); + // And the ESC went down the ESCAPE channel, never the raw one. + expect(terminal.raw).not.toContain('\x1b'); + expect(result.abortReason).toMatch(/still mid-turn/i); + }); + + it('never escalates before the receipt is accepted', async () => { + const clock = makeClock(); + const terminal = makeTerminal({}); + vi.spyOn(terminal, 'observe').mockImplementation(async () => ({ + exists: true, + lastDataAt: clock.now(), + })); + + const result = await runReset( + baseOptions({ clock, terminal, quiesceTimeoutMs: 5_000, quiescePostEscalationTimeoutMs: 3_000 }) as never, + ); + + const order = names(result.steps); + // The ordering that matters: an ESC before the receipt could interrupt the + // very save being requested, destroying the thing reset exists to preserve. + expect(order.indexOf('receipt-accepted')).toBeGreaterThan(-1); + expect(order.indexOf('escalate-esc')).toBeGreaterThan(order.indexOf('receipt-accepted')); + }); + + it('refuses to clear when the Tower cannot report lastDataAt', async () => { + // An older Tower omits the field. Treating undefined as 0 would compute an + // age of decades and clear a busy builder instantly — the exact R4 breach + // phase 2 exists to prevent. + const terminal = makeTerminal({ quietness: [undefined] }); + const result = await runReset(baseOptions({ terminal }) as never); + + expect(result.outcome).toBe('aborted'); + expect(names(result.steps)).not.toContain('clear'); + expect(result.abortReason).toMatch(/lastDataAt/); + }); + + it('distinguishes a vanished terminal from an old Tower that omits lastDataAt', async () => { + // Both surface as "no lastDataAt", but they need opposite responses: one is + // "upgrade Tower", the other is "your builder's terminal died, here is where + // its saved state lives". Conflating them sends the architect to check a + // version number while the builder is gone. + let alive = true; + const terminal = makeTerminal({}); + vi.spyOn(terminal, 'observe').mockImplementation(async () => { + if (!alive) return { exists: false }; + alive = false; // dies right after the receipt is accepted + return { exists: true, lastDataAt: 0 }; + }); + + const result = await runReset(baseOptions({ terminal }) as never); + + expect(result.outcome).toBe('aborted'); + expect(names(result.steps)).not.toContain('clear'); + expect(result.abortReason).toMatch(/lost its terminal/); + // Explicitly NOT the old-Tower diagnosis. + expect(result.abortReason).not.toMatch(/lastDataAt/); + // And it points at the state file, which outlives the terminal. + expect(result.abortReason).toMatch(/\.builder-state\.md/); + }); + + it('clears once the terminal goes quiet after being busy', async () => { + const clock = makeClock(); + let busy = true; + const terminal = makeTerminal({}); + vi.spyOn(terminal, 'observe').mockImplementation(async () => { + const observation = { exists: true, lastDataAt: busy ? clock.now() : 0 }; + busy = false; // quiet from the second observation onward + return observation; + }); + + const result = await runReset(baseOptions({ clock, terminal }) as never); + + expect(result.outcome).toBe('completed'); + expect(names(result.steps)).toContain('clear'); + expect(terminal.escapes).toBe(0); + }); +}); + +// ============================================================================ +// Preflight refusals +// ============================================================================ + +describe('Spec 1273 — preflight refusals happen before any write', () => { + it('aborts loudly on a harness with no in-session reset, naming the harness', async () => { + const terminal = makeTerminal({ quietness: QUIET }); + const fs = makeFs({}); + + await expect( + runReset(baseOptions({ + context: makeContext({ + harnessName: 'codex', + harness: { supportsContextReset: false } as ResolvedBuilderContext['harness'], + }), + terminal, + fs, + }) as never), + ).rejects.toThrow(/codex/); + + expect(terminal.messages).toHaveLength(0); + expect(terminal.raw).toHaveLength(0); + expect(fs.writes).toHaveLength(0); + }); + + it('aborts on a non-writable terminal BEFORE writing the re-orientation file', async () => { + // #1198: a session whose shellper connection died reports status 'running' + // while dropping every write. Without this check the command writes + // .builder-reorient.md into the worktree, sends a save request into a void, + // and then fails — having already touched the builder for a reset that + // could never proceed. The plan's contract is validate-before-touch. + const fs = makeFs({}); + const terminal = makeTerminal({ quietness: QUIET }); + vi.spyOn(terminal, 'observe').mockResolvedValue({ + exists: true, + lastDataAt: 0, + writable: false, + }); + + await expect(runReset(baseOptions({ terminal, fs }) as never)).rejects.toThrow( + /not accepting input/, + ); + + expect(fs.writes).toHaveLength(0); + expect(terminal.messages).toHaveLength(0); + expect(terminal.raw).toHaveLength(0); + }); + + it('proceeds when writability is unreported, since that failure is loud', async () => { + // Deliberate asymmetry with lastDataAt. An unobservable TURN STATE fails + // silently and destructively (clear a builder mid-turn), so it refuses. An + // unobservable WRITE PATH fails loudly and harmlessly (the send throws), so + // an older Tower is not blocked from resetting. + const terminal = makeTerminal({ quietness: QUIET }); + vi.spyOn(terminal, 'observe').mockResolvedValue({ exists: true, lastDataAt: 0 }); + + const result = await runReset(baseOptions({ terminal }) as never); + expect(result.outcome).toBe('completed'); + }); + + it('aborts when the builder has no live terminal', async () => { + const fs = makeFs({}); + await expect( + runReset(baseOptions({ terminal: makeTerminal({ exists: false }), fs }) as never), + ).rejects.toThrow(/no live terminal/); + expect(fs.writes).toHaveLength(0); + }); + + it('refuses a state-file override that escapes the worktree', async () => { + const terminal = makeTerminal({ quietness: QUIET }); + await expect( + runReset(baseOptions({ terminal, stateFileName: '../../../etc/evil.md' }) as never), + ).rejects.toThrow(/outside the builder's worktree/); + expect(terminal.messages).toHaveLength(0); + }); +}); + +// ============================================================================ +// --dry-run, --note / --file, --interrupt-first +// ============================================================================ + +describe('Spec 1273 — CLI-facing behaviour', () => { + it('--dry-run exposes the save request so the command can print it', async () => { + // The command surface prints `result.saveRequest`. When the orchestrator + // kept the request internal, `--dry-run` printed an empty line under a + // "save request" header — the contract said "print the save request and + // both payload parts" and one of the three was silently blank. + const result = await runReset(baseOptions({ dryRun: true }) as never); + + expect(result.saveRequest).toBeTruthy(); + expect(result.saveRequest).toContain('.builder-state.md'); + // The nonce must be in the request: it is what the R2 gate later verifies, + // so a request without it describes a save that could never be accepted. + expect(result.saveRequest).toContain(result.nonce); + }); + + it('sends the builder exactly the save request the dry run advertised', async () => { + // Guards the gap between "what --dry-run showed" and "what a real run + // sends". A dry run is only useful as a preview if it previews the real + // thing. + const dry = await runReset(baseOptions({ dryRun: true }) as never); + const terminal = makeTerminal({ quietness: QUIET }); + const live = await runReset(baseOptions({ terminal }) as never); + + // Nonces differ per run, so compare the request with the nonce factored out. + const shape = (text: string, nonce: string) => text.split(nonce).join('<NONCE>'); + expect(shape(terminal.messages[0], live.nonce)).toBe(shape(dry.saveRequest, dry.nonce)); + }); + + it('--dry-run performs ZERO writes to the builder', async () => { + const fs = makeFs({}); + const terminal = makeTerminal({ quietness: QUIET }); + const result = await runReset(baseOptions({ fs, terminal, dryRun: true }) as never); + + expect(result.outcome).toBe('dry-run'); + expect(terminal.messages).toHaveLength(0); + expect(terminal.raw).toHaveLength(0); + // Not even the long-form file: a dry run must not alter the worktree either. + expect(fs.writes).toHaveLength(0); + // But it still proves assembly succeeded — that is the point of the run. + expect(result.payload?.inline).toContain('CONTEXT RESET'); + }); + + it('places the addendum in the delivered payload', async () => { + const terminal = makeTerminal({ quietness: QUIET }); + await runReset(baseOptions({ terminal, addendum: 'Ignore the stale PR comment.' }) as never); + + expect(terminal.messages[1]).toContain('Ignore the stale PR comment.'); + }); + + it('--interrupt-first sends ESC before the save request, not after', async () => { + const terminal = makeTerminal({ quietness: QUIET }); + const result = await runReset(baseOptions({ terminal, interruptFirst: true }) as never); + + const order = names(result.steps); + expect(order.indexOf('interrupt-first')).toBeLessThan(order.indexOf('send-save-request')); + expect(terminal.escapes).toBe(1); + }); + + it('sends no pre-emptive ESC by default', async () => { + const terminal = makeTerminal({ quietness: QUIET }); + const result = await runReset(baseOptions({ terminal }) as never); + + expect(names(result.steps)).not.toContain('interrupt-first'); + // The only raw write on the happy path is the clear itself, and no ESC. + expect(terminal.raw).toEqual(['/clear']); + expect(terminal.escapes).toBe(0); + }); +}); + +// ============================================================================ +// Scenario 14a — the wedged builder (the incident this feature came from) +// ============================================================================ + +describe('Spec 1273 scenario 14a — a wedged builder recovers via --interrupt-first', () => { + /** + * Simulates the failure this whole feature exists for. + * + * A builder chains foreground waits inside one turn. Every `afx send` — + * including the save request — queues UNREAD until the turn ends, so the + * state file never appears and the terminal never goes quiet. ESC ends the + * turn; the queued messages then process. Verified in production (shannon, + * 2026-07-27): a builder wedged 45+ minutes resumed within two minutes of + * receiving ESC. + * + * The wedge is modelled at the only place it is observable to reset: the + * builder does not act on messages, and its terminal keeps emitting. The ESC + * is what flips both. + */ + function makeWedgedBuilder(clock: ReturnType<typeof makeClock>) { + let awake = false; + let nonce = ''; + const raw: string[] = []; + const messages: string[] = []; + let escapes = 0; + + const terminal: TerminalPort & { raw: string[]; messages: string[]; escapes: number } = { + raw, + messages, + get escapes() { + return escapes; + }, + async observe() { + // Mid-turn the PTY emits continuously; once the turn ends it falls silent. + return { exists: true, lastDataAt: awake ? 0 : clock.now() }; + }, + async sendMessage(message: string) { + messages.push(message); + // A wedged builder RECEIVES the message but never reads it — the whole + // point of the wedge. The nonce is only learned once awake. + if (awake) { + const match = message.match(/([0-9a-f]{12})/); + if (match) nonce = match[1]; + } + }, + async sendRaw(text: string) { + raw.push(text); + }, + async sendEscape() { + escapes++; + awake = true; + // The queued save request now processes: re-read what was already sent. + for (const m of messages) { + const match = m.match(/([0-9a-f]{12})/); + if (match) nonce = match[1]; + } + }, + }; + + const fs: ResetFsPort & { writes: Array<{ path: string; content: string }> } = { + writes: [], + read(path: string) { + if (!path.endsWith('.builder-state.md')) return null; + return nonce ? `nonce ${nonce}\nworking state, written for a cold reader` : null; + }, + sizeOf(path: string) { + if (!path.endsWith('.builder-state.md')) return null; + return nonce ? 5000 : null; + }, + write(path: string, content: string) { + this.writes.push({ path, content }); + }, + }; + + return { terminal, fs }; + } + + it('completes the full flow when --interrupt-first breaks the turn', async () => { + const clock = makeClock(); + const { terminal, fs } = makeWedgedBuilder(clock); + + const result = await runReset( + baseOptions({ + clock, + terminal, + fs, + interruptFirst: true, + receiptTimeoutMs: 60_000, + quiesceTimeoutMs: 20_000, + }) as never, + ); + + expect(result.outcome).toBe('completed'); + const order = names(result.steps); + // The ESC precedes the save request — that ordering is what makes the + // request readable at all. + expect(order.indexOf('interrupt-first')).toBeLessThan(order.indexOf('send-save-request')); + expect(order).toContain('receipt-accepted'); + expect(order).toContain('clear'); + expect(terminal.raw).toContain('/clear'); + }); + + it('aborts without clearing when the same builder is reset WITHOUT the flag', async () => { + // The control case, and the more important half: it proves the flag is what + // made the difference rather than the harness being permissive. Same wedged + // builder, no --interrupt-first — the request is never read, the receipt + // never verifies, and nothing is cleared. + const clock = makeClock(); + const { terminal, fs } = makeWedgedBuilder(clock); + + const result = await runReset( + baseOptions({ clock, terminal, fs, receiptTimeoutMs: 20_000 }) as never, + ); + + expect(result.outcome).toBe('aborted'); + expect(names(result.steps)).not.toContain('clear'); + expect(terminal.raw).not.toContain('/clear'); + expect(terminal.escapes).toBe(0); + // And the abort message points at the recovery, so an architect hitting + // this learns the flag exists at the moment they need it. + expect(result.abortReason).toMatch(/--interrupt-first/); + }); +}); + +// ============================================================================ +// Timing parameters cannot be used to switch a gate off +// ============================================================================ + +describe('Spec 1273 — a bad timing parameter aborts, it does not weaken a gate', () => { + // Each of these does not merely misconfigure the run — it disables a specific + // invariant while the run still reports success. That is the failure mode the + // whole step-log design exists to make impossible, so it must not be + // reachable through a number. + const cases: Array<{ label: string; option: string; value: number; gate: string }> = [ + { label: 'a negative quiet window', option: 'quietWindowMs', value: -1, gate: 'R4' }, + { label: 'a zero quiet window', option: 'quietWindowMs', value: 0, gate: 'R4' }, + { label: 'a negative minimum size', option: 'minBytes', value: -1, gate: 'R2' }, + { label: 'a NaN receipt timeout', option: 'receiptTimeoutMs', value: NaN, gate: 'R2' }, + { label: 'an infinite receipt timeout', option: 'receiptTimeoutMs', value: Infinity, gate: 'R2' }, + { label: 'a zero quiesce timeout', option: 'quiesceTimeoutMs', value: 0, gate: 'R4' }, + ]; + + for (const { label, option, value, gate } of cases) { + it(`rejects ${label} (${gate}) before touching the builder`, async () => { + const terminal = makeTerminal({ quietness: QUIET }); + const fs = makeFs({}); + + await expect( + runReset(baseOptions({ terminal, fs, [option]: value }) as never), + ).rejects.toThrow(ResetPreflightError); + + expect(terminal.messages).toHaveLength(0); + expect(terminal.raw).toHaveLength(0); + expect(fs.writes).toHaveLength(0); + }); + } + + it('would otherwise let a negative quiet window pass quiescence instantly', async () => { + // Demonstrates WHY the guard matters rather than only that it fires. With a + // permanently-busy terminal and no guard, `now - lastDataAt >= -1` is always + // true, so R4 would be satisfied without the builder ever going quiet. + const clock = makeClock(); + const terminal = makeTerminal({}); + vi.spyOn(terminal, 'observe').mockImplementation(async () => ({ + exists: true, + lastDataAt: clock.now(), + })); + + await expect( + runReset(baseOptions({ clock, terminal, quietWindowMs: -1 }) as never), + ).rejects.toThrow(/quietWindowMs/); + + // Never cleared, because the run never started. + expect(terminal.raw).not.toContain('/clear'); + }); +}); + +// ============================================================================ +// Cross-module coupling: afx cleanup's scaffold classification +// ============================================================================ + +describe('Spec 1273 — reset artifacts do not make a worktree look dirty', () => { + it('both artifact names match afx cleanup\'s scaffold pattern', () => { + // `cleanup.ts` classifies untracked files as scaffold with this pattern. + // It is duplicated here deliberately: the coupling is by CONVENTION (a + // filename prefix), not by a shared symbol, so nothing would fail if a + // future rename dropped the prefix. The consequence would be quiet and + // annoying — every reset builder's worktree reported dirty, blocking + // cleanup — so it is worth a test that fails loudly instead. + const scaffoldPattern = /^\?\? \.builder-/; + + expect(scaffoldPattern.test(`?? ${STATE_FILE_NAME}`)).toBe(true); + expect(scaffoldPattern.test(`?? ${REORIENT_FILE_NAME}`)).toBe(true); + }); + + it('writes the long form only to a .builder- prefixed path', async () => { + const fs = makeFs({}); + await runReset(baseOptions({ fs }) as never); + + for (const write of fs.writes) { + expect(write.path.split('/').pop()).toMatch(/^\.builder-/); + } + }); +}); + +// ============================================================================ +// Reporting +// ============================================================================ + +describe('Spec 1273 — the report carries evidence, not reassurance', () => { + it('reports the accepted state-file size rather than a bare tick', async () => { + const result = await runReset(baseOptions() as never); + const report = formatResetReport(result); + + expect(report).toContain('receipt-accepted'); + expect(report).toMatch(/receipt-accepted — \d+ bytes/); + }); + + it('states plainly that the context was NOT cleared on an abort', async () => { + const result = await runReset( + baseOptions({ fs: makeFs({ bytes: 120 }), receiptTimeoutMs: 10_000 }) as never, + ); + const report = formatResetReport(result); + + expect(report).toContain('ABORTED'); + expect(report).toContain('was NOT cleared'); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-1273-reset-receipt.test.ts b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-receipt.test.ts new file mode 100644 index 000000000..648d627bf --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-receipt.test.ts @@ -0,0 +1,317 @@ +/** + * Spec 1273 Phase 3 — the reset receipt gate (invariant R2). + * + * R2: `/clear` is never written unless the state file has been verified to + * (a) carry THIS run's nonce, (b) meet a minimum-substance threshold, and + * (c) be size-stable across observations. A stale file from a previous reset + * MUST NOT satisfy the gate. + * + * These tests are the negative space of that invariant: each rejection path gets + * its own case, because the failure this gate prevents — clearing a builder's + * context before its state landed — is irreversible. + */ + +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { + generateNonce, + nonceMarker, + buildSaveRequest, + verifyReceipt, + describeReceiptFailure, + stateFilePath, + type ReceiptFsPort, + type ReceiptObservation, +} from '../commands/reset/receipt.js'; +import { DEFAULT_MIN_BYTES, DEFAULT_STABILITY_WINDOW_MS, STATE_FILE_NAME } from '../commands/reset/constants.js'; + +// ============================================================================ +// Helpers +// ============================================================================ + +const PATH = '/tmp/ws/.builders/aspir-1273/.builder-state.md'; + +/** In-memory fs port — no real files, no real clock. */ +function makeFs(files: Record<string, string>): ReceiptFsPort { + return { + sizeOf: (p) => (p in files ? Buffer.byteLength(files[p], 'utf-8') : null), + read: (p) => (p in files ? files[p] : null), + }; +} + +/** A state file that would pass on substance, containing the given nonce. */ +function substantiveFile(nonce: string, bytes = DEFAULT_MIN_BYTES + 500): string { + const header = `${nonceMarker(nonce)}\n`; + return header + 'x'.repeat(Math.max(0, bytes - Buffer.byteLength(header, 'utf-8'))); +} + +/** A prior observation that satisfies everything except the caller's own checks. */ +function priorObservation(bytes: number): ReceiptObservation { + return { status: 'still-growing', bytes }; +} + +// ============================================================================ +// Nonce +// ============================================================================ + +describe('reset nonce (Spec 1273)', () => { + it('generates a different nonce per run', () => { + const seen = new Set(Array.from({ length: 50 }, () => generateNonce())); + // If two runs could collide, a stale file could satisfy a later run's gate. + expect(seen.size).toBe(50); + }); + + it('embeds the nonce in an HTML comment so it is invisible when rendered', () => { + const marker = nonceMarker('abc123'); + expect(marker).toBe('<!-- codev-reset: abc123 -->'); + expect(marker.startsWith('<!--')).toBe(true); + }); +}); + +// ============================================================================ +// Save request +// ============================================================================ + +describe('buildSaveRequest (Spec 1273)', () => { + const nonce = 'deadbeef0000'; + const request = buildSaveRequest(nonce, PATH); + + it('carries the exact marker line the gate will look for', () => { + expect(request).toContain(nonceMarker(nonce)); + }); + + it('names the exact target path', () => { + expect(request).toContain(PATH); + }); + + it('tells the builder the file is untracked and must not be staged', () => { + // porch done sweeps staged files — a staged state file would vanish. + expect(request.toLowerCase()).toContain('untracked'); + expect(request.toLowerCase()).toMatch(/do not stage|not stage/); + }); + + it('asks for every item on the cold-reader checklist', () => { + // The gate is structural, so this wording is the only thing driving quality. + const lower = request.toLowerCase(); + expect(lower).toContain('cold reader'); + expect(lower).toContain('role and mission'); + expect(lower).toContain('position in the protocol'); + expect(lower).toContain('receipts'); + expect(lower).toContain('in-flight'); + expect(lower).toContain('open questions'); + expect(lower).toContain('standing orders'); + expect(lower).toContain('next concrete action'); + }); + + it('asks the builder to distinguish written from verified', () => { + expect(request.toLowerCase()).toContain('verified'); + }); +}); + +// ============================================================================ +// R2 — the gate itself +// ============================================================================ + +describe('verifyReceipt — R2 gate (Spec 1273)', () => { + const nonce = 'a1b2c3d4e5f6'; + + it('accepts a nonce-bearing, substantive, size-stable file', () => { + const file = substantiveFile(nonce); + const bytes = Buffer.byteLength(file, 'utf-8'); + const result = verifyReceipt({ + fs: makeFs({ [PATH]: file }), + statePath: PATH, + nonce, + previous: priorObservation(bytes), + msSincePrevious: DEFAULT_STABILITY_WINDOW_MS, + }); + + expect(result.status).toBe('accepted'); + expect(result.bytes).toBe(bytes); + }); + + it('rejects a missing file', () => { + const result = verifyReceipt({ fs: makeFs({}), statePath: PATH, nonce }); + expect(result.status).toBe('missing'); + }); + + it('rejects a stale file from a PREVIOUS reset (wrong nonce)', () => { + // The headline R2 case: substantive, stable, plausible — and superseded. + const stale = substantiveFile('old-nonce-999'); + const result = verifyReceipt({ + fs: makeFs({ [PATH]: stale }), + statePath: PATH, + nonce, + previous: priorObservation(Buffer.byteLength(stale, 'utf-8')), + msSincePrevious: DEFAULT_STABILITY_WINDOW_MS * 10, + }); + + expect(result.status).toBe('wrong-nonce'); + }); + + it('rejects a nonce-bearing stub below the substance threshold', () => { + const stub = `${nonceMarker(nonce)}\nsaved everything, all good\n`; + const result = verifyReceipt({ + fs: makeFs({ [PATH]: stub }), + statePath: PATH, + nonce, + previous: priorObservation(Buffer.byteLength(stub, 'utf-8')), + msSincePrevious: DEFAULT_STABILITY_WINDOW_MS, + }); + + expect(result.status).toBe('too-small'); + }); + + it('rejects a file that is still growing between observations', () => { + const file = substantiveFile(nonce, 4000); + const result = verifyReceipt({ + fs: makeFs({ [PATH]: file }), + statePath: PATH, + nonce, + previous: priorObservation(2000), // smaller a moment ago → still being written + msSincePrevious: DEFAULT_STABILITY_WINDOW_MS, + }); + + expect(result.status).toBe('still-growing'); + }); + + it('does not accept two same-size reads taken closer together than the stability window', () => { + // Without the time gap, a builder mid-write looks stable purely because both + // reads landed between the same two write() calls. + const file = substantiveFile(nonce); + const bytes = Buffer.byteLength(file, 'utf-8'); + const result = verifyReceipt({ + fs: makeFs({ [PATH]: file }), + statePath: PATH, + nonce, + previous: priorObservation(bytes), + msSincePrevious: DEFAULT_STABILITY_WINDOW_MS - 1, + }); + + expect(result.status).toBe('still-growing'); + }); + + it('never accepts on a first observation, however substantive the file', () => { + const file = substantiveFile(nonce, 50_000); + const result = verifyReceipt({ fs: makeFs({ [PATH]: file }), statePath: PATH, nonce }); + + expect(result.status).toBe('still-growing'); + }); + + it('reports the most specific failure: freshness is checked before substance', () => { + // A stale stub is BOTH wrong-nonce and too-small. Reporting "too-small" + // would send the architect chasing --min-bytes for a staleness problem. + const staleStub = `${nonceMarker('previous-run')}\ntiny\n`; + const result = verifyReceipt({ fs: makeFs({ [PATH]: staleStub }), statePath: PATH, nonce }); + + expect(result.status).toBe('wrong-nonce'); + }); + + it('honours a caller-supplied minBytes override', () => { + const small = `${nonceMarker(nonce)}\nshort but genuinely all there was\n`; + const bytes = Buffer.byteLength(small, 'utf-8'); + const result = verifyReceipt({ + fs: makeFs({ [PATH]: small }), + statePath: PATH, + nonce, + minBytes: 10, + previous: priorObservation(bytes), + msSincePrevious: DEFAULT_STABILITY_WINDOW_MS, + }); + + expect(result.status).toBe('accepted'); + }); + + it('accepts a nonce whose surrounding marker whitespace differs', () => { + // Freshness is proved by the nonce; discarding a real save over comment + // spacing would be a false rejection of work that cost the builder real effort. + const reworded = `<!--codev-reset:${nonce}-->\n` + 'y'.repeat(DEFAULT_MIN_BYTES); + const bytes = Buffer.byteLength(reworded, 'utf-8'); + const result = verifyReceipt({ + fs: makeFs({ [PATH]: reworded }), + statePath: PATH, + nonce, + previous: priorObservation(bytes), + msSincePrevious: DEFAULT_STABILITY_WINDOW_MS, + }); + + expect(result.status).toBe('accepted'); + }); + + it('performs no writes — verification is read-only', () => { + const calls: string[] = []; + const fs: ReceiptFsPort = { + sizeOf: (p) => { calls.push(`sizeOf:${p}`); return 0; }, + read: (p) => { calls.push(`read:${p}`); return ''; }, + }; + + verifyReceipt({ fs, statePath: PATH, nonce }); + + expect(calls.every(c => c.startsWith('sizeOf:') || c.startsWith('read:'))).toBe(true); + }); +}); + +// ============================================================================ +// Failure reporting +// ============================================================================ + +describe('describeReceiptFailure (Spec 1273)', () => { + it('points a missing file at --interrupt-first', () => { + const msg = describeReceiptFailure({ status: 'missing' }, PATH); + expect(msg).toContain('--interrupt-first'); + }); + + it('says a wrong-nonce file is stale rather than blaming the builder', () => { + const msg = describeReceiptFailure({ status: 'wrong-nonce', bytes: 9000 }, PATH); + expect(msg.toLowerCase()).toContain('stale'); + expect(msg).toContain('9000'); + }); + + it('offers --min-bytes when the file is a stub', () => { + const msg = describeReceiptFailure({ status: 'too-small', bytes: 120 }, PATH); + expect(msg).toContain('--min-bytes'); + expect(msg).toContain(String(DEFAULT_MIN_BYTES)); + }); + + it('says a partial save was refused rather than reporting a timeout', () => { + const msg = describeReceiptFailure({ status: 'still-growing', bytes: 400 }, PATH); + expect(msg.toLowerCase()).toContain('partial'); + }); +}); + +// ============================================================================ +// Paths +// ============================================================================ + +describe('stateFilePath (Spec 1273)', () => { + it('places the state file at the worktree root with the .builder- prefix', () => { + // The prefix keeps `afx cleanup` classifying the worktree as clean. + const p = stateFilePath('/tmp/ws/.builders/aspir-1273'); + expect(p).toBe(join('/tmp/ws/.builders/aspir-1273', STATE_FILE_NAME)); + expect(STATE_FILE_NAME.startsWith('.builder-')).toBe(true); + }); + + it('tolerates a trailing separator on the worktree path', () => { + expect(stateFilePath('/tmp/ws/.builders/aspir-1273/')).toBe( + join('/tmp/ws/.builders/aspir-1273', STATE_FILE_NAME), + ); + }); + + it('uses platform path joining, not string concatenation', () => { + // The path is handed to the builder verbatim in the save request. If it were + // built with a hardcoded '/', a Windows worktree would yield + // `C:\repo\wt\/.builder-state.md` — the builder would write to one path and + // the gate would stat another. Asserting against path.join keeps this test + // meaningful on whichever platform it runs. + const worktree = join('C:', 'repo', 'wt'); + expect(stateFilePath(worktree)).toBe(join(worktree, STATE_FILE_NAME)); + expect(stateFilePath(worktree)).not.toContain('\\/'); + expect(stateFilePath(worktree)).not.toContain('//'); + }); + + it('produces no doubled separator for any trailing-separator form', () => { + for (const wt of ['/a/b', '/a/b/', '/a/b//']) { + expect(stateFilePath(wt)).toBe(join('/a/b', STATE_FILE_NAME)); + } + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-1273-reset-reorient.test.ts b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-reorient.test.ts new file mode 100644 index 000000000..3c133e4d6 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-1273-reset-reorient.test.ts @@ -0,0 +1,548 @@ +/** + * Spec 1273 Phase 5 — re-orientation assembly (invariant R3). + * + * R3: the re-orientation always carries the full frame — role, protocol, mode, + * project identity, worktree, branch, state-file pointer, porch re-entry. There + * must be no code path that emits a partial one. + * + * The failure this prevents is silent, which is why the tests lean on the + * negative cases: a frame missing its protocol does not crash, it produces a + * builder with a fresh window that does not know what governs it, and the drift + * only surfaces later as off-protocol work. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + assembleReorientation, + conditionalInlineMarkers, + ReorientationAssemblyError, + REQUIRED_INLINE_MARKERS, + type SpawnPromptPort, +} from '../commands/reset/reorient.js'; +import { REORIENT_FILE_NAME } from '../commands/reset/constants.js'; +import type { ResolvedBuilderContext } from '../commands/reset/context.js'; + +// ============================================================================ +// Fixtures +// ============================================================================ + +const STATE_PATH = '/ws/.builders/aspir-1273/.builder-state.md'; + +const SPAWN_PROMPT = '# ASPIR Builder\n\nProtocol reference: ...\n'; + +const spawnPromptPort: SpawnPromptPort = () => SPAWN_PROMPT; + +/** Stand-in for the real `buildResumeNotice`, including its porch init fallback. */ +const RESUME_NOTICE = `## RESUME SESSION + +Start by running \`porch next\` to check your current state and get next tasks. +If porch reports "not found", run \`porch init\` to re-initialize. +`; +const resumeNoticePort = () => RESUME_NOTICE; + +function makeContext(overrides: Partial<ResolvedBuilderContext> = {}): ResolvedBuilderContext { + return { + builderId: 'builder-aspir-1273', + worktree: '/ws/.builders/aspir-1273', + branch: 'builder/aspir-1273', + protocol: 'aspir', + protocolSource: 'status.yaml', + mode: 'strict', + modeSource: 'builder-prompt', + harnessName: 'claude', + harness: { supportsContextReset: true } as any, + porch: { + projectId: '1273', + projectName: '1273-builder-context-reset-should-b', + protocol: 'aspir', + phase: 'implement', + currentPlanPhase: 'phase_5', + statusPath: '/ws/.builders/aspir-1273/codev/projects/1273-x/status.yaml', + }, + specName: '1273-builder-context-reset-should-b', + specPath: 'codev/specs/1273-builder-context-reset-should-b.md', + planPath: 'codev/plans/1273-builder-context-reset-should-b.md', + issueNumber: '1273', + ...overrides, + }; +} + +function assemble(overrides: Partial<ResolvedBuilderContext> = {}, addendum?: string) { + return assembleReorientation({ + context: makeContext(overrides), + statePath: STATE_PATH, + addendum, + buildSpawnPrompt: spawnPromptPort, + buildResumeNotice: resumeNoticePort, + }); +} + +// ============================================================================ +// R3 — the complete frame +// ============================================================================ + +describe('assembleReorientation — R3 complete frame (Spec 1273)', () => { + it('produces every required frame element inline', () => { + const { inline } = assemble(); + for (const marker of REQUIRED_INLINE_MARKERS) { + expect(inline).toContain(marker); + } + }); + + it('names the protocol, mode, worktree and branch', () => { + const { inline } = assemble(); + expect(inline).toContain('ASPIR'); + expect(inline).toContain('STRICT'); + expect(inline).toContain('/ws/.builders/aspir-1273'); + expect(inline).toContain('builder/aspir-1273'); + }); + + it('points at the state file and tells the builder to read it in full', () => { + const { inline } = assemble(); + expect(inline).toContain(STATE_PATH); + expect(inline).toContain('in full'); + }); + + it('warns the builder not to stage the state file', () => { + // porch done sweeps staged files; a staged state file would vanish. + const { inline } = assemble(); + expect(inline.toLowerCase()).toContain('do not stage'); + }); + + it('points at the long-form file', () => { + const { inline, longFormFileName } = assemble(); + expect(longFormFileName).toBe(REORIENT_FILE_NAME); + expect(inline).toContain(REORIENT_FILE_NAME); + }); + + it('tells the builder its history is gone and not to try to recall it', () => { + // A reset builder that tries to "remember" confabulates; the frame has to + // redirect it to the files. + const { inline } = assemble(); + expect(inline.toLowerCase()).toContain('cleared'); + expect(inline.toLowerCase()).toContain('do not try to recall'); + }); + + it('adds the porch re-entry instruction on a porch lane', () => { + const { inline } = assemble(); + expect(inline).toContain('porch next'); + }); + + it('omits porch re-entry on a non-porch lane and still satisfies R3', () => { + const { inline } = assemble({ porch: null, specName: null, specPath: null, planPath: null, issueNumber: undefined }); + expect(inline).not.toContain('porch next'); + for (const marker of REQUIRED_INLINE_MARKERS) { + expect(inline).toContain(marker); + } + }); + + it('names the project, project ID and issue on a porch lane', () => { + // Without these a reset builder cannot locate its own project — as + // load-bearing on a porch lane as the protocol name itself. The ID is + // stated explicitly rather than left implicit in the directory stem: + // `porch status`/`porch next` take the id, and a builder with no history + // should not have to parse it back out of a slug. + const { inline } = assemble(); + expect(inline).toContain('Project ID: 1273'); + expect(inline).toContain('Project:'); + expect(inline).toContain('1273-builder-context-reset-should-b'); + expect(inline).toContain('Issue:'); + expect(inline).toContain('#1273'); + }); + + it('names WHICH role document governs the builder, not just that one does', () => { + // A builder with no conversation history cannot resolve "your role + // document" to a file. The spec requires the identity block to name it. + const { inline } = assemble(); + expect(inline).toContain('.builder-role.md'); + }); + + it('records the porch project ID in the long form too', () => { + const { longForm } = assemble(); + expect(longForm).toContain('Porch project ID: 1273'); + }); + + it('requires project identity and porch re-entry whenever the lane is porch-driven', () => { + // Conditional, not optional: the marker list adapts to what the lane has, + // so "missing input is an abort, not an omission" still applies. + const markers = conditionalInlineMarkers(makeContext()); + expect(markers).toContain('Project ID:'); + expect(markers).toContain('Project:'); + expect(markers).toContain('porch next'); + expect(markers).toContain('Issue:'); + }); + + it('requires no project or porch markers on a non-porch lane', () => { + const markers = conditionalInlineMarkers( + makeContext({ porch: null, issueNumber: undefined }), + ); + expect(markers).toEqual([]); + }); + + it('requires the issue marker whenever an issue number is known', () => { + const markers = conditionalInlineMarkers(makeContext({ porch: null })); + expect(markers).toEqual(['Issue:']); + }); +}); + +// ============================================================================ +// R3 — complete-or-abort +// ============================================================================ + +describe('assembleReorientation — abort rather than partial (Spec 1273)', () => { + it.each([ + ['protocol', { protocol: '' }], + ['mode', { mode: '' as any }], + ['worktree', { worktree: '' }], + ['branch', { branch: '' }], + ['builderId', { builderId: '' }], + ])('throws a named error when %s is missing', (field, override) => { + expect(() => assemble(override as Partial<ResolvedBuilderContext>)).toThrow(ReorientationAssemblyError); + expect(() => assemble(override as Partial<ResolvedBuilderContext>)).toThrow(new RegExp(field)); + }); + + it('throws when the state path is empty', () => { + expect(() => + assembleReorientation({ + context: makeContext(), + statePath: '', + buildSpawnPrompt: spawnPromptPort, + }), + ).toThrow(/statePath/); + }); + + it.each([ + ['projectId', { projectId: '' }], + ['projectName', { projectName: '' }], + ['phase', { phase: '' }], + ])('throws when porch.%s is empty on a porch lane', (field, override) => { + // Marker validation matches on LABELS, so an empty projectId still renders + // `Project ID:` and passes it — a frame that looks complete and tells the + // builder nothing. Presence of a label is not presence of a value. + const base = makeContext(); + expect(() => + assemble({ porch: { ...base.porch!, ...(override as object) } as any }), + ).toThrow(ReorientationAssemblyError); + expect(() => + assemble({ porch: { ...base.porch!, ...(override as object) } as any }), + ).toThrow(new RegExp(`porch.${field}`)); + }); + + it('still assembles when only the optional plan phase is absent', () => { + // currentPlanPhase is genuinely optional — a porch lane between plan phases + // has none — so it must not be swept into the required set. + const base = makeContext(); + expect(() => + assemble({ porch: { ...base.porch!, currentPlanPhase: null } }), + ).not.toThrow(); + }); + + it('aborts when a porch lane has no re-entry notice available', () => { + // A porch-driven builder without its re-entry instruction is the partial + // frame R3 forbids. + expect(() => + assembleReorientation({ + context: makeContext(), + statePath: STATE_PATH, + buildSpawnPrompt: spawnPromptPort, + }), + ).toThrow(/re-entry/); + }); + + it('aborts when the spawn prompt cannot be rendered', () => { + // A long form without protocol framing is the partial frame R3 forbids, and + // it would land on a builder with no context left to notice the gap. + const failing: SpawnPromptPort = () => { + throw new Error('no builder-prompt.md for protocol'); + }; + expect(() => + assembleReorientation({ + context: makeContext(), + statePath: STATE_PATH, + buildSpawnPrompt: failing, + }), + ).toThrow(/Refusing to clear without it/); + }); + + it('fails assembly if a required marker is ever dropped from the frame', () => { + // Guards the invariant itself: if a refactor removed an element from the + // rendered frame, assembly must fail rather than ship the gap. Simulated by + // asserting the validation list is actually consulted. + expect(REQUIRED_INLINE_MARKERS.length).toBeGreaterThan(0); + const { inline } = assemble(); + const rendered = REQUIRED_INLINE_MARKERS.filter(m => inline.includes(m)); + expect(rendered.length).toBe(REQUIRED_INLINE_MARKERS.length); + }); +}); + +// ============================================================================ +// Long form +// ============================================================================ + +describe('long-form re-orientation (Spec 1273)', () => { + it('embeds the spawn prompt verbatim rather than paraphrasing it', () => { + // This is the concrete discharge of "re-inject phase context the way + // --resume does" — the same builder prompt a fresh launch delivers. + const { longForm } = assemble(); + expect(longForm).toContain(SPAWN_PROMPT.trim()); + }); + + it('embeds the porch re-entry notice verbatim from the shared source', () => { + // Reset must not restate it: buildResumeNotice carries the `porch init` + // fallback, and a restated copy drops it while the two surfaces drift. + const { longForm } = assemble(); + expect(longForm).toContain(RESUME_NOTICE.trim()); + expect(longForm).toContain('porch init'); + }); + + it('omits the re-entry notice on a non-porch lane', () => { + const { longForm } = assemble({ porch: null, specName: null, specPath: null, planPath: null }); + expect(longForm).not.toContain('RESUME SESSION'); + }); + + it('calls the spawn prompt port with the resolved protocol and mode flags', () => { + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ context: makeContext(), statePath: STATE_PATH, buildSpawnPrompt: port, buildResumeNotice: resumeNoticePort }); + + expect(port).toHaveBeenCalledWith('aspir', expect.objectContaining({ + protocol_name: 'ASPIR', + mode: 'strict', + mode_strict: true, + mode_soft: false, + project_id: '1273', + })); + }); + + it('passes spec and plan into the template context when they exist', () => { + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ context: makeContext(), statePath: STATE_PATH, buildSpawnPrompt: port, buildResumeNotice: resumeNoticePort }); + + const ctx = (port as any).mock.calls[0][1]; + expect(ctx.spec).toEqual({ + path: 'codev/specs/1273-builder-context-reset-should-b.md', + name: '1273-builder-context-reset-should-b', + }); + // Assert `name` too, symmetric with `spec`: reusing specName for the plan is + // intentional (porch names spec and plan from the same stem), and an + // asymmetric assertion would let that convention break unnoticed. + expect(ctx.plan).toEqual({ + path: 'codev/plans/1273-builder-context-reset-should-b.md', + name: '1273-builder-context-reset-should-b', + }); + }); + + it('omits spec and plan when the files do not exist', () => { + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ + context: makeContext({ specPath: null, planPath: null }), + statePath: STATE_PATH, + buildSpawnPrompt: port, + buildResumeNotice: resumeNoticePort, + }); + + const ctx = (port as any).mock.calls[0][1]; + expect(ctx.spec).toBeUndefined(); + expect(ctx.plan).toBeUndefined(); + }); + + it('forwards issue number, title and body into the spawn prompt context', () => { + // Every issue-driven protocol's builder prompt renders {{issue.number}}, + // {{issue.title}} and {{issue.body}} — and on BUGFIX/AIR the body IS the + // spec. Without this the long form is spawn-shaped, not spawn-equivalent, + // and a reset builder on those lanes loses its requirements. + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ + context: makeContext(), + statePath: STATE_PATH, + buildSpawnPrompt: port, + buildResumeNotice: resumeNoticePort, + issue: { number: '1273', title: 'Builder context reset', body: 'The problem is...' }, + }); + + const ctx = (port as any).mock.calls[0][1]; + expect(ctx.issue).toEqual({ + number: '1273', + title: 'Builder context reset', + body: 'The problem is...', + }); + }); + + // ========================================================================== + // input_description — one case per spawn entry point. + // + // This is the FIRST line of every protocol's builder prompt, so a wrong value + // mis-frames the entire document. These four tests pin the reconstruction to + // spawn's four literal strings; if spawn's wording changes, they fail here + // rather than silently drifting apart from it. + // ========================================================================== + + it('frames a spec-driven lane exactly as the spec-driven spawn path does', () => { + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ context: makeContext(), statePath: STATE_PATH, buildSpawnPrompt: port, buildResumeNotice: resumeNoticePort }); + + // spawn.ts:455 + expect((port as any).mock.calls[0][1].input_description).toBe( + 'the feature specified in codev/specs/1273-builder-context-reset-should-b.md', + ); + }); + + it('frames an issue-driven lane as GitHub-issue work, not as a bare protocol', () => { + // BUGFIX/AIR have no spec — the issue body IS the spec. Before this was + // fixed, such a lane fell through to the protocol-only wording and a reset + // builder was told it was "running the BUGFIX protocol" rather than working + // a specific issue. + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ + context: makeContext({ protocol: 'bugfix', specName: null, specPath: null, planPath: null, issueNumber: '1288' }), + statePath: STATE_PATH, + buildSpawnPrompt: port, + buildResumeNotice: resumeNoticePort, + }); + + // spawn.ts:837 + expect((port as any).mock.calls[0][1].input_description).toBe('work for GitHub Issue #1288'); + }); + + it('frames an ad-hoc task lane as a task and forwards the task text', () => { + // Order regression: a --task builder gets a porch project keyed on its + // builder id, so issueNumber is populated for it too. Testing issueNumber + // first would announce a GitHub issue that does not exist. + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ + context: makeContext({ + specName: null, specPath: null, planPath: null, + issueNumber: 'task-abc', taskText: 'Audit the retry logic', + }), + statePath: STATE_PATH, + buildSpawnPrompt: port, + buildResumeNotice: resumeNoticePort, + }); + + const ctx = (port as any).mock.calls[0][1]; + // spawn.ts:543 + expect(ctx.input_description).toBe('an ad-hoc task'); + expect(ctx.task_text).toBe('Audit the retry logic'); + }); + + it('frames a protocol-only lane with spawn\'s exact wording', () => { + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ + context: makeContext({ specName: null, specPath: null, planPath: null, issueNumber: undefined, porch: null }), + statePath: STATE_PATH, + buildSpawnPrompt: port, + }); + + // spawn.ts:607 — note "running the", which the earlier restatement dropped. + expect((port as any).mock.calls[0][1].input_description).toBe('running the ASPIR protocol'); + }); + + it('omits task_text on every lane that is not an ad-hoc task', () => { + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ context: makeContext(), statePath: STATE_PATH, buildSpawnPrompt: port, buildResumeNotice: resumeNoticePort }); + + expect((port as any).mock.calls[0][1].task_text).toBeUndefined(); + }); + + it('omits the issue from the prompt context when none was supplied', () => { + const port = vi.fn(() => SPAWN_PROMPT) as unknown as SpawnPromptPort; + assembleReorientation({ + context: makeContext(), + statePath: STATE_PATH, + buildSpawnPrompt: port, + buildResumeNotice: resumeNoticePort, + }); + + expect((port as any).mock.calls[0][1].issue).toBeUndefined(); + }); + + it('makes an unfetchable issue a VISIBLE gap with a recovery instruction', () => { + // Silent omission is the dangerous failure here: on BUGFIX/AIR the builder + // would infer requirements from whatever framing survived. Reset does not + // hard-fail on a forge outage — it says what is missing and how to get it. + const { longForm } = assemble(); + expect(longForm).toContain('could not be fetched'); + expect(longForm).toContain('gh issue view 1273'); + }); + + it('does not warn about a missing issue when the issue was supplied', () => { + const { longForm } = assembleReorientation({ + context: makeContext(), + statePath: STATE_PATH, + buildSpawnPrompt: spawnPromptPort, + buildResumeNotice: resumeNoticePort, + issue: { number: '1273', title: 't', body: 'b' }, + }); + expect(longForm).not.toContain('could not be fetched'); + }); + + it('does not warn about a missing issue on a lane that has no issue', () => { + const { longForm } = assemble({ porch: null, issueNumber: undefined, specName: null, specPath: null, planPath: null }); + expect(longForm).not.toContain('could not be fetched'); + }); + + it('gives the read order with the state file first', () => { + // The state file is what the previous session actually knew; protocol + // framing is reconstructible, working state is not. + const { longForm } = assemble(); + const stateIdx = longForm.indexOf(STATE_PATH); + const framingIdx = longForm.indexOf('Protocol framing'); + expect(stateIdx).toBeGreaterThan(-1); + expect(stateIdx).toBeLessThan(framingIdx); + }); + + it('records where protocol and mode were resolved from, so the frame is auditable', () => { + const { longForm } = assemble(); + expect(longForm).toContain('status.yaml'); + expect(longForm).toContain('builder-prompt'); + }); + + it('marks itself untracked and regenerated', () => { + const { longForm } = assemble(); + expect(longForm).toContain('Untracked'); + }); +}); + +// ============================================================================ +// Architect addendum +// ============================================================================ + +describe('architect addendum (Spec 1273)', () => { + it('appears in both parts, flagged as post-dating the save', () => { + const note = 'PR #1280 merged since your save; rebase before continuing.'; + const { inline, longForm } = assemble({}, note); + + expect(inline).toContain(note); + expect(inline.toLowerCase()).toContain('post-dates your save'); + expect(longForm).toContain(note); + }); + + it('is omitted cleanly when absent', () => { + const { inline } = assemble(); + expect(inline.toLowerCase()).not.toContain('from the architect'); + }); + + it('is omitted when only whitespace', () => { + const { inline } = assemble({}, ' \n '); + expect(inline.toLowerCase()).not.toContain('from the architect'); + }); +}); + +// ============================================================================ +// Message-channel fitness +// ============================================================================ + +describe('inline frame stays fit for the message channel (Spec 1273)', () => { + it('does not inline the full role document', () => { + // The role survives /clear in --append-system-prompt, and re-sending + // hundreds of lines through a paced, paste-detection-prone channel would be + // both slow and risky. R3 is satisfied by the identity block. + const { inline } = assemble(); + expect(inline).toContain('You are a Builder'); + expect(inline.split('\n').length).toBeLessThan(40); + }); + + it('stays well under the 48KB send cap', () => { + const { inline } = assemble({}, 'x'.repeat(500)); + expect(Buffer.byteLength(inline, 'utf-8')).toBeLessThan(8 * 1024); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-1273-wait-discipline-docs.test.ts b/packages/codev/src/agent-farm/__tests__/spec-1273-wait-discipline-docs.test.ts new file mode 100644 index 000000000..c9e1021f5 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-1273-wait-discipline-docs.test.ts @@ -0,0 +1,130 @@ +/** + * Wait-discipline guidance and command docs — Spec 1273, phase 7. + * + * These are docs tests, which are usually a smell. They earn their place here + * because of a specific, repeatedly-observed failure in this repo: guidance and + * reference material live in FOUR parallel trees — `codev/` (this workspace's + * copies) and `codev-skeleton/` (what adopters get), each with a Claude and a + * Codex skill variant. Updating one and forgetting its twin is the single most + * common documentation defect here, and it is invisible in review because the + * file you are reading looks correct. + * + * The standing lesson is "after any framework change, grep BOTH trees". This + * file is that grep, executed. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, '../../../../..'); + +const read = (rel: string) => readFileSync(resolve(REPO, rel), 'utf-8'); + +const ROLE_DOCS = ['codev/roles/builder.md', 'codev-skeleton/roles/builder.md']; +const COMMAND_DOCS = [ + 'codev/resources/commands/agent-farm.md', + 'codev-skeleton/resources/commands/agent-farm.md', +]; +const SKILL_DOCS = ['.claude/skills/afx/SKILL.md', '.codex/skills/afx/SKILL.md']; + +describe('Spec 1273 phase 7 — wait discipline reaches the builder role doc', () => { + for (const doc of ROLE_DOCS) { + describe(doc, () => { + it('carries the wait-discipline section', () => { + expect(read(doc)).toContain('## Waiting on external work'); + }); + + it('states all three rules', () => { + const text = read(doc); + // 1. A wait claims a producer exists. + expect(text).toMatch(/wait is a claim that a producer exists/i); + // 2. Background tasks that end the turn. + expect(text).toMatch(/background tasks that end your turn/i); + // 3. Never chain foreground poll loops — the rule the incident turned on. + expect(text).toMatch(/never chain foreground poll loops/i); + }); + + it('explains WHY the poll-loop rule matters, not just the rule', () => { + // A rule without its reason does not survive contact with a builder that + // thinks its case is special. The reason is that an unending turn makes + // the builder unreachable by everyone, including the order to stop. + const text = read(doc); + expect(text).toMatch(/queues unread until your current turn ends/i); + }); + + it('names the escape hatch so a stuck builder knows it can be reached', () => { + const text = read(doc); + expect(text).toContain('afx interrupt'); + expect(text).toContain('afx reset'); + }); + }); + } + + it('keeps the two role-doc copies byte-identical', () => { + // The four-tier resolver means `codev/roles/builder.md` SHADOWS the skeleton + // copy for this workspace. Skeleton-only would leave our own builders without + // the guidance; codev-only would leave every adopter without it. Drift + // between them is silent, so it is asserted. + const [ours, skeleton] = ROLE_DOCS.map(read); + expect(ours).toBe(skeleton); + }); +}); + +describe('Spec 1273 phase 7 — both commands are documented where they are looked up', () => { + for (const doc of COMMAND_DOCS) { + describe(doc, () => { + it('documents afx reset and afx interrupt', () => { + const text = read(doc); + expect(text).toContain('### afx reset'); + expect(text).toContain('### afx interrupt'); + }); + + it('documents every reset flag the CLI accepts', () => { + // Kept in sync with cli.ts's registration by hand; a flag that exists + // and is undocumented is a flag nobody uses. + const text = read(doc); + for (const flag of [ + '--note', + '--file', + '--dry-run', + '--interrupt-first', + '--mode', + '--timeout', + '--min-bytes', + '--quiet-window', + ]) { + expect(text).toContain(flag); + } + }); + + it('states the fail-safe property rather than only listing steps', () => { + // The reason an architect can run this on a live builder at all is that + // every gate aborts without clearing. If the docs omit that, the command + // reads as far more dangerous than it is and goes unused. + expect(read(doc)).toMatch(/aborts?\s+\*?\*?without clearing/i); + }); + }); + } +}); + +describe('Spec 1273 phase 7 — both skill trees, not just the Claude one', () => { + for (const doc of SKILL_DOCS) { + it(`${doc} lists afx reset and afx interrupt`, () => { + // The repo maintains parallel Claude and Codex skill trees. Updating only + // the Claude one leaves Codex-driven agents unable to discover the + // commands — they would have no reason to believe reset exists. + expect(existsSync(resolve(REPO, doc))).toBe(true); + const text = read(doc); + expect(text).toContain('## afx reset'); + expect(text).toContain('## afx interrupt'); + }); + } + + it('keeps the two skill trees byte-identical', () => { + const [claude, codex] = SKILL_DOCS.map(read); + expect(claude).toBe(codex); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts index c5324dd18..530ec46a4 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts @@ -948,6 +948,51 @@ describe('tower-routes', () => { }); // ========================================================================= + // GET /api/terminals/:id — the wire contract for quiescence (Spec 1273) + // ========================================================================= + + describe('GET /api/terminals/:id (Spec 1273 — lastDataAt on the wire)', () => { + // Testing `session.info` alone would not pin this: the whole point of the + // phase is that the field reaches a *client*, so afx reset can measure + // output quiescence instead of assuming a builder's turn has ended before + // typing /clear into its terminal. This asserts the serialised response. + it('serialises lastDataAt as an epoch-ms number', async () => { + const lastDataAt = 1_753_660_000_000; + mockGetTerminalManager.mockReturnValue({ + getSession: () => ({ + info: { + id: 'term-42', pid: 4242, cols: 80, rows: 24, label: 'builder', + status: 'running', createdAt: '2026-07-28T00:00:00.000Z', lastDataAt, + }, + }), + listSessions: () => [], + }); + + const req = makeReq('GET', '/api/terminals/term-42'); + const { res, statusCode, body } = makeRes(); + await handleRequest(req, res, makeCtx()); + + expect(statusCode()).toBe(200); + const parsed = JSON.parse(body()); + expect(typeof parsed.lastDataAt).toBe('number'); + expect(parsed.lastDataAt).toBe(lastDataAt); + }); + + it('returns 404 for an unknown terminal rather than a body without lastDataAt', async () => { + mockGetTerminalManager.mockReturnValue({ + getSession: () => undefined, + listSessions: () => [], + }); + + const req = makeReq('GET', '/api/terminals/term-gone'); + const { res, statusCode, body } = makeRes(); + await handleRequest(req, res, makeCtx()); + + expect(statusCode()).toBe(404); + expect(JSON.parse(body()).error).toBe('NOT_FOUND'); + }); + }); + // DELETE /api/terminals/:id (Bugfix #290) // ========================================================================= @@ -1338,6 +1383,109 @@ describe('tower-routes', () => { expect(mockWrite).not.toHaveBeenCalled(); }); + // Spec 1273: `escape` delivers a bare ESC keystroke straight to the PTY. + // The buffer-bypass assertion is the load-bearing one — an interrupt that can + // be deferred because someone recently typed in that terminal is not an + // interrupt, and a wedged builder is precisely the case where you cannot wait. + it('writes a bare ESC and never defers it, even when the user is actively typing (Spec 1273)', async () => { + mockParseJsonBody.mockResolvedValue({ + to: '1273', message: '\x1b', workspace: '/tmp/ws', options: { escape: true }, + }); + mockResolveTarget.mockReturnValue({ + terminalId: 'term-wedged', + workspacePath: '/tmp/ws', + agent: 'builder-aspir-1273', + }); + const mockWrite = vi.fn(); + mockGetTerminalManager.mockReturnValue({ + // isUserIdle() === false is what forces deferral on the normal send path. + getSession: () => ({ write: mockWrite, pid: 1234, writable: true, isUserIdle: () => false, composing: false }), + listSessions: () => [], + }); + const req = makeReq('POST', '/api/send'); + const { res, statusCode, body } = makeRes(); + + await handleRequest(req, res, makeCtx()); + + expect(statusCode()).toBe(200); + const parsed = JSON.parse(body()); + expect(parsed.ok).toBe(true); + expect(parsed.deferred).toBe(false); + // ESC written immediately and unformatted — no header/wrapper text. + expect(mockWrite).toHaveBeenCalledWith('\x1b'); + expect(mockWrite.mock.calls[0][0]).toBe('\x1b'); + }); + + it('accepts a lone ESC message body without tripping the non-empty guard (Spec 1273)', async () => { + // The ESC recovery depends on `\x1b` surviving handleSend's trim(); a 400 + // here would mean the only mid-turn recovery had been silently broken. + mockParseJsonBody.mockResolvedValue({ + to: '1273', message: '\x1b', workspace: '/tmp/ws', options: { escape: true }, + }); + mockResolveTarget.mockReturnValue({ + terminalId: 'term-wedged', + workspacePath: '/tmp/ws', + agent: 'builder-aspir-1273', + }); + mockGetTerminalManager.mockReturnValue({ + getSession: () => ({ write: vi.fn(), pid: 1234, writable: true, isUserIdle: () => true, composing: false }), + listSessions: () => [], + }); + const req = makeReq('POST', '/api/send'); + const { res, statusCode } = makeRes(); + + await handleRequest(req, res, makeCtx()); + + expect(statusCode()).toBe(200); + }); + + it('fails loudly on a non-writable terminal instead of reporting a delivered ESC (Spec 1273)', async () => { + mockParseJsonBody.mockResolvedValue({ + to: '1273', message: '\x1b', workspace: '/tmp/ws', options: { escape: true }, + }); + mockResolveTarget.mockReturnValue({ + terminalId: 'term-zombie', + workspacePath: '/tmp/ws', + agent: 'builder-aspir-1273', + }); + const mockWrite = vi.fn(); + mockGetTerminalManager.mockReturnValue({ + getSession: () => ({ write: mockWrite, pid: 1234, writable: false, isUserIdle: () => true, composing: false }), + listSessions: () => [], + }); + const req = makeReq('POST', '/api/send'); + const { res, statusCode, body } = makeRes(); + + await handleRequest(req, res, makeCtx()); + + expect(statusCode()).toBe(503); + expect(JSON.parse(body()).error).toBe('TERMINAL_NOT_WRITABLE'); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it('leaves normal sends unaffected when escape is absent (Spec 1273 regression guard)', async () => { + mockParseJsonBody.mockResolvedValue({ to: 'architect', message: 'hello', workspace: '/tmp/ws' }); + mockResolveTarget.mockReturnValue({ + terminalId: 'term-001', + workspacePath: '/tmp/ws', + agent: 'architect', + }); + const mockWrite = vi.fn(); + mockGetTerminalManager.mockReturnValue({ + getSession: () => ({ write: mockWrite, pid: 1234, writable: true, isUserIdle: () => true, composing: false }), + listSessions: () => [], + }); + const req = makeReq('POST', '/api/send'); + const { res, statusCode } = makeRes(); + + await handleRequest(req, res, makeCtx()); + + expect(statusCode()).toBe(200); + // Formatted, not a bare ESC. + expect(mockWrite).toHaveBeenCalled(); + expect(mockWrite.mock.calls[0][0]).not.toBe('\x1b'); + }); + it('returns deferred:true when user is actively typing (Spec 403)', async () => { mockParseJsonBody.mockResolvedValue({ to: 'architect', message: 'hello', workspace: '/tmp/ws' }); mockResolveTarget.mockReturnValue({ diff --git a/packages/codev/src/agent-farm/cli.ts b/packages/codev/src/agent-farm/cli.ts index 7a2ea0128..38b871406 100644 --- a/packages/codev/src/agent-farm/cli.ts +++ b/packages/codev/src/agent-farm/cli.ts @@ -470,6 +470,73 @@ export async function runAgentFarm(args: string[]): Promise<void> { } }); + // Interrupt command (Spec 1273) — ESC into a builder's PTY + program + .command('interrupt [builder]') + .description('Interrupt a builder mid-turn (sends ESC to end the running turn)') + .option('--no-enter', 'Send ESC alone, without the trailing Enter') + .action(async (builder, options) => { + const { interrupt } = await import('./commands/interrupt.js'); + try { + await interrupt({ builder, noEnter: !options.enter }); + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }); + + // Reset command (Spec 1273) — save-state → /clear → re-orient + program + .command('reset [builder]') + .description('Reset a builder\'s context: save working state, clear, then re-orient') + .option('--note <text>', 'Extra context to append to the re-orientation') + .option('--file <path>', 'Append file content to the re-orientation (48KB max)') + .option('--dry-run', 'Print what would be sent; write nothing to the builder') + .option('--interrupt-first', 'Send ESC before the save request (for a builder wedged mid-turn)') + .option('--mode <mode>', 'Override the builder mode (strict|soft) if it cannot be detected') + .option('--timeout <seconds>', 'How long to wait for the save-state receipt') + .option('--min-bytes <n>', 'Minimum state-file size to accept as substantive') + .option('--quiet-window <ms>', 'Terminal silence that counts as turn-ended') + .action(async (builder, options) => { + const { reset } = await import('./commands/reset.js'); + try { + if (options.mode && options.mode !== 'strict' && options.mode !== 'soft') { + logger.error(`--mode must be 'strict' or 'soft', got '${options.mode}'`); + process.exit(1); + } + // Every one of these tunes a SAFETY GATE, so a bad value does not + // degrade the run — it disables a protection while still reporting + // success. `--quiet-window -1` makes the quiescence check pass + // instantly (R4 gone), `--min-bytes -1` accepts any state file however + // empty (R2's substance floor gone), and a non-numeric `--timeout` + // yields NaN, whose comparisons are all false, so the receipt wait + // never expires and the command hangs. Reject at the boundary. + const positiveInt = (raw: string | undefined, flag: string): number | undefined => { + if (raw === undefined) return undefined; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + logger.error(`${flag} must be a positive integer, got '${raw}'`); + process.exit(1); + } + return parsed; + }; + await reset({ + builder, + note: options.note, + file: options.file, + dryRun: options.dryRun, + interruptFirst: options.interruptFirst, + mode: options.mode, + timeout: positiveInt(options.timeout, '--timeout'), + minBytes: positiveInt(options.minBytes, '--min-bytes'), + quietWindow: positiveInt(options.quietWindow, '--quiet-window'), + }); + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }); + // Bench command - consultation benchmarking program .command('bench') diff --git a/packages/codev/src/agent-farm/commands/interrupt.ts b/packages/codev/src/agent-farm/commands/interrupt.ts new file mode 100644 index 000000000..67c527478 --- /dev/null +++ b/packages/codev/src/agent-farm/commands/interrupt.ts @@ -0,0 +1,73 @@ +/** + * Interrupt command — send a bare ESC keystroke into a builder's PTY (Spec 1273). + * + * This is the only recovery that reaches a builder *mid-turn*. When a builder + * chains foreground waits inside one turn, every `afx send` — including the + * architect's order to stop — queues unread until the turn ends. ESC interrupts + * the running tool, ends the turn, and the queued messages then process. + * + * Verified in production (shannon workspace, 2026-07-27): a builder wedged for + * 45+ minutes on a wait for a file whose producer had already died resumed + * within two minutes of receiving ESC. Until now that recipe + * (`afx send <builder> --raw "$(printf '\x1b')"`) lived only in architect lore + * and had to be discovered under pressure. + * + * Addressing, workspace detection and sender identity are reused verbatim from + * `afx send` — there is exactly one address resolver. + */ + +import type { InterruptOptions } from '../types.js'; +import { logger, fatal } from '../utils/logger.js'; +import { TowerClient } from '../lib/tower-client.js'; +import { detectWorkspaceRoot, detectCurrentBuilderId } from './send.js'; + +export async function interrupt(options: InterruptOptions): Promise<void> { + const target = options.builder; + + if (!target) { + fatal('Must specify a builder. Usage: afx interrupt <builder>'); + } + + logger.header('Sending Interrupt (ESC)'); + + const workspace = detectWorkspaceRoot() ?? undefined; + + // Same identity rule as `afx send`: in a confirmed builder worktree an + // unverifiable canonical id aborts rather than sending as an unverified + // sender, which Tower would silently route to 'main' (issue #1094). + let from: string; + try { + from = detectCurrentBuilderId() ?? 'architect'; + } catch (err) { + fatal(err instanceof Error ? err.message : String(err)); + } + + const client = new TowerClient(); + if (!(await client.isRunning())) { + fatal('Tower is not running. Start it with: afx tower start'); + } + + try { + // `message` carries the ESC byte so the route's non-empty validation is + // satisfied and both this command and the manual `--raw` recipe exercise the + // same byte. `escape` is what makes delivery immediate and unformatted. + const result = await client.sendMessage(target, '\x1b', { + from, + workspace, + fromWorkspace: workspace, + escape: true, + noEnter: options.noEnter, + }); + + if (!result.ok) { + throw new Error(result.error || 'Unknown error'); + } + + logger.success(`Interrupt (ESC) sent to ${result.resolvedTo ?? target}`); + if (!options.noEnter) { + logger.info('Enter followed the ESC — any messages queued during the turn should now process.'); + } + } catch (error) { + fatal(error instanceof Error ? error.message : String(error)); + } +} diff --git a/packages/codev/src/agent-farm/commands/reset.ts b/packages/codev/src/agent-farm/commands/reset.ts new file mode 100644 index 000000000..e5e021a6c --- /dev/null +++ b/packages/codev/src/agent-farm/commands/reset.ts @@ -0,0 +1,324 @@ +/** + * `afx reset` — the command surface over the reset state machine (Spec 1273). + * + * This file is deliberately thin. It does three things and nothing else: + * resolves the target, binds REAL implementations to the orchestrator's ports, + * and prints the report. Every decision, every ordering rule and every refusal + * lives in `reset/index.ts`, where it is testable without Tower, a PTY, or a + * live builder. + * + * That split is the point. The dangerous part of reset is the ordering, and + * ordering is only provable if the thing that decides it has no I/O in it. + * + * Addressing, workspace detection and sender identity are reused verbatim from + * `afx send` — there is exactly one address resolver (the same rule `afx + * interrupt` follows). + */ + +import { existsSync, readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'; +import { TowerClient } from '../lib/tower-client.js'; +import { logger, fatal } from '../utils/logger.js'; +import { findBuilderById } from '../lib/builder-lookup.js'; +import { getConfig } from '../utils/index.js'; +import { loadConfig } from '../../lib/config.js'; +import { loadForgeConfig } from '../../lib/forge.js'; +import { fetchIssue as fetchForgeIssue } from '../../lib/github.js'; +import { buildPromptFromTemplate, buildResumeNotice } from './spawn-roles.js'; +import { detectWorkspaceRoot, detectCurrentBuilderId } from './send.js'; +import { resolveBuilderContext } from './reset/context.js'; +import { + formatResetReport, + runReset, + ResetPreflightError, + type ClockPort, + type ResetFsPort, + type TerminalPort, +} from './reset/index.js'; +import type { IssuePayload } from './reset/reorient.js'; +import type { CustomHarnessConfig } from '../utils/harness.js'; +import type { ResetOptions } from '../types.js'; + +/** Same cap `afx send --file` enforces. One rule for architect-supplied files. */ +const MAX_FILE_SIZE = 48 * 1024; + +export async function reset(options: ResetOptions): Promise<void> { + const target = options.builder; + if (!target) { + fatal('Must specify a builder. Usage: afx reset <builder>'); + } + + logger.header('Builder Context Reset'); + + const workspace = detectWorkspaceRoot() ?? undefined; + + let from: string; + try { + from = detectCurrentBuilderId() ?? 'architect'; + } catch (err) { + fatal(err instanceof Error ? err.message : String(err)); + } + + // `findBuilderById`, NOT `getBuilder`: the latter matches the id EXACTLY, so + // `afx reset 1273` would fail against a builder registered as `aspir-1273` + // while `afx send 1273` reached it fine. Reset must resolve targets the same + // way every other builder-addressed command does — a reset that cannot be + // addressed the way the architect already types addresses is a reset that + // gets typed wrong under pressure. `findBuilderById` also reports AMBIGUOUS + // with the candidate list rather than silently picking one. + const builder = findBuilderById(target); + if (!builder) { + fatal( + `No builder '${target}' in this workspace (or the id is ambiguous — see above). ` + + `Check 'afx status'. Reset needs the registry row for the worktree and branch.`, + ); + } + if (!builder.worktree || !builder.branch) { + fatal( + `Builder '${target}' has an incomplete registry row (worktree='${builder.worktree}', ` + + `branch='${builder.branch}'). Refusing to reset against unresolved state.`, + ); + } + + const client = new TowerClient(); + if (!(await client.isRunning())) { + fatal('Tower is not running. Start it with: afx tower start'); + } + + const addendum = buildAddendum(options); + const config = getConfig(); + const userConfig = loadConfig(config.workspaceRoot); + + const context = resolveBuilderContext({ + fs: { + exists: (p: string) => existsSync(p), + read: (p: string) => safeRead(p), + listDirs: (p: string) => { + try { + return readdirSync(p, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { + return null; + } + }, + }, + builderId: builder.id, + worktree: builder.worktree, + branch: builder.branch, + issueNumber: builder.issueNumber === undefined ? undefined : String(builder.issueNumber), + taskText: builder.taskText, + modeOverride: options.mode, + customHarnesses: userConfig?.harness as Record<string, CustomHarnessConfig> | undefined, + }); + + const terminal: TerminalPort = buildTerminalPort(client, builder.terminalId, target, from, workspace); + + try { + const result = await runReset({ + context, + fs: buildFsPort(), + clock: realClock, + terminal, + buildSpawnPrompt: (protocol, templateContext) => + buildPromptFromTemplate(config, protocol, templateContext), + buildResumeNotice, + issue: await fetchIssuePayload(context.issueNumber, config.workspaceRoot), + addendum, + dryRun: options.dryRun, + interruptFirst: options.interruptFirst, + receiptTimeoutMs: options.timeout ? options.timeout * 1000 : undefined, + minBytes: options.minBytes, + quietWindowMs: options.quietWindow, + }); + + if (result.outcome === 'dry-run') { + logger.info('DRY RUN — nothing was written to the builder.\n'); + logger.info('--- save request (what the builder is asked to write) ---'); + console.log(result.saveRequest); + logger.info('--- inline re-orientation ---'); + console.log(result.payload?.inline ?? ''); + logger.info(`--- long form would be written to ${result.reorientPath} ---`); + console.log(result.payload?.longForm ?? ''); + return; + } + + console.log(formatResetReport(result)); + + if (result.outcome === 'aborted') { + // Non-zero: an aborted reset is a failure the caller must see, even though + // it is the SAFE outcome. Silence here would let a script treat "refused + // to clear" as "cleared". + process.exitCode = 1; + return; + } + + logger.success(`Builder ${target} reset and re-oriented.`); + logger.info(`State file: ${result.statePath} (${result.stateBytes} bytes)`); + } catch (err) { + if (err instanceof ResetPreflightError) { + fatal(err.message); + } + fatal(err instanceof Error ? err.message : String(err)); + } +} + +// ============================================================================ +// Port bindings +// ============================================================================ + +const realClock: ClockPort = { + now: () => Date.now(), + sleep: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)), +}; + +function buildFsPort(): ResetFsPort { + return { + read: (p: string) => safeRead(p), + sizeOf: (p: string) => { + try { + return statSync(p).size; + } catch { + return null; + } + }, + write: (p: string, content: string) => writeFileSync(p, content, 'utf-8'), + }; +} + +function safeRead(p: string): string | null { + try { + return readFileSync(p, 'utf-8'); + } catch { + return null; + } +} + +function buildTerminalPort( + client: TowerClient, + terminalId: string | undefined, + target: string, + from: string, + workspace: string | undefined, +): TerminalPort { + return { + async observe() { + if (!terminalId) return { exists: false }; + const t = await client.getTerminal(terminalId); + if (!t || t.status !== 'running') return { exists: false }; + // Both optional fields are forwarded AS-IS, including undefined. The + // orchestrator distinguishes "reported false" from "not reported", and + // collapsing either to a concrete value here would defeat that check at + // the boundary — `lastDataAt: 0` reads as decades of silence, and + // `writable: true` would assert a fact this Tower never supplied. + return { exists: true, lastDataAt: t.lastDataAt, writable: t.writable }; + }, + async sendMessage(message: string) { + const result = await client.sendMessage(target, message, { + from, + workspace, + fromWorkspace: workspace, + }); + if (!result.ok) throw new Error(result.error || 'Message delivery failed'); + }, + /** + * `raw: true`, NOT `escape: true`. + * + * Tower's escape route writes a hardcoded ESC and discards the message + * body, so binding this to `escape` would silently turn `/clear` into an + * interrupt: the run would report success while the builder kept its + * entire context. `raw` types the text as literal input, which is what the + * verified manual recipe used. + */ + async sendRaw(text: string) { + const result = await client.sendMessage(target, text, { + from, + workspace, + fromWorkspace: workspace, + raw: true, + }); + if (!result.ok) throw new Error(result.error || 'Raw write failed'); + }, + async sendEscape() { + const result = await client.sendMessage(target, '\x1b', { + from, + workspace, + fromWorkspace: workspace, + escape: true, + }); + if (!result.ok) throw new Error(result.error || 'Interrupt (ESC) failed'); + }, + /** + * Bound for real, not left undefined. + * + * When this was absent the orchestrator's `confirmClear` returned false on + * every production run, so the report always said "clear-unconfirmed" — a + * step that looked attempted and could never succeed outside tests. + * + * Returns `total` alongside the lines so confirmation can look only at + * output produced AFTER the clear. Reset writes into this same terminal, so + * without that window any pattern eventually matches reset's own text. + */ + async readOutput() { + if (!terminalId) return null; + const output = await client.getTerminalOutput(terminalId, 200); + return output ? { lines: output.lines, total: output.total } : null; + }, + }; +} + +// ============================================================================ +// Inputs +// ============================================================================ + +/** + * Assemble the architect addendum from `--note` and `--file`. + * + * `--file` reads from the CALLER's filesystem, exactly as `afx send --file` + * does, and reuses its 48KB cap. The worktree-containment rule applies to the + * state-file path override, not to this — the architect is reading their own + * notes, not instructing the builder where to write. + */ +function buildAddendum(options: ResetOptions): string | undefined { + const parts: string[] = []; + if (options.note) parts.push(options.note); + if (options.file) { + if (!existsSync(options.file)) { + fatal(`File not found: ${options.file}`); + } + const buf = readFileSync(options.file); + if (buf.length > MAX_FILE_SIZE) { + fatal(`File too large: ${buf.length} bytes (max ${MAX_FILE_SIZE} bytes / 48KB)`); + } + parts.push(buf.toString('utf-8')); + } + return parts.length > 0 ? parts.join('\n\n') : undefined; +} + +/** + * Fetch issue metadata for the long form, best-effort. + * + * A forge outage must not block a reset: phase 5 renders an explicit "could not + * be fetched" gap with a `gh issue view` recovery line, which is strictly better + * than aborting a reset the architect needs, and strictly better than silently + * omitting requirements. + */ +async function fetchIssuePayload( + issueNumber: string | undefined, + workspaceRoot: string, +): Promise<IssuePayload | undefined> { + if (!issueNumber) return undefined; + try { + const issue = await fetchForgeIssue(issueNumber, { + cwd: workspaceRoot, + forgeConfig: loadForgeConfig(workspaceRoot), + }); + if (!issue) return undefined; + return { + number: issueNumber, + title: issue.title, + body: issue.body || '(No description provided)', + }; + } catch { + return undefined; + } +} diff --git a/packages/codev/src/agent-farm/commands/reset/constants.ts b/packages/codev/src/agent-farm/commands/reset/constants.ts new file mode 100644 index 000000000..e93764698 --- /dev/null +++ b/packages/codev/src/agent-farm/commands/reset/constants.ts @@ -0,0 +1,60 @@ +/** + * Tunable parameters for `afx reset` (Spec 1273). + * + * Every value here is overridable from the CLI. The defaults are grounded in + * the one verified manual reset (shannon workspace, 2026-07-27), not guessed. + */ + +/** + * Fixed name of the builder's working-state file, at the worktree root. + * + * The `.builder-` prefix is load-bearing: `afx cleanup` classifies untracked + * `.builder-*` files as scaffold rather than dirt, so a worktree carrying one is + * still considered clean. It is untracked, which is why `porch done`'s + * staged-file sweep cannot pick it up. + * + * The name is deliberately FIXED rather than nonce-suffixed. Freshness is proven + * by the nonce *inside* the file (R2), so encoding it in the filename would add + * nothing and would leave a new litter file after every reset. + */ +export const STATE_FILE_NAME = '.builder-state.md'; + +/** Long-form re-orientation written before the clear (R1). Same prefix rationale. */ +export const REORIENT_FILE_NAME = '.builder-reorient.md'; + +/** + * Minimum size for a state file to count as substantive. + * + * The one verified example ran 203 lines (~8-10KB). A genuine cold-reader save + * is comfortably over 1KB; a three-line stub is 100-200 bytes. 1000 rejects + * stubs without false-rejecting a terse but real save. + */ +export const DEFAULT_MIN_BYTES = 1000; + +/** Gap between the two observations that must agree for the file to count as stable. */ +export const DEFAULT_STABILITY_WINDOW_MS = 2000; + +/** How often to stat the state file while waiting for it. */ +export const DEFAULT_POLL_INTERVAL_MS = 2000; + +/** + * How long to wait for the builder to produce the state file. + * + * A busy builder may take minutes to reach the request — the manual run was of + * this order. Expiry aborts WITHOUT clearing (R2). + */ +export const DEFAULT_RECEIPT_TIMEOUT_MS = 300_000; + +/** + * How long the terminal must produce no output before it counts as quiescent. + * + * An agent mid-turn emits continuously (spinner frames, streamed tokens), so a + * true silence of this length reliably indicates the turn ended. + */ +export const DEFAULT_QUIET_WINDOW_MS = 1500; + +/** Bounded wait for quiescence before the single ESC escalation (R4). */ +export const DEFAULT_QUIESCE_TIMEOUT_MS = 60_000; + +/** Bounded wait after the ESC escalation. Shorter — ESC should act immediately. */ +export const DEFAULT_QUIESCE_POST_ESCALATION_TIMEOUT_MS = 30_000; diff --git a/packages/codev/src/agent-farm/commands/reset/context.ts b/packages/codev/src/agent-farm/commands/reset/context.ts new file mode 100644 index 000000000..eeee23b47 --- /dev/null +++ b/packages/codev/src/agent-farm/commands/reset/context.ts @@ -0,0 +1,414 @@ +/** + * Builder context resolution for `afx reset` (Spec 1273, phase 4). + * + * Invariant R3 requires a re-orientation carrying protocol, mode, worktree, + * branch, project identity and porch re-entry. The first draft of the plan + * assumed the builder registry held those facts. **It does not**, and the gap is + * silent rather than obvious: + * + * - `builders.protocol_name` is NULL for spec-type builders. `spawn.ts` never + * passes `protocolName` on that path (only the `protocol`-type spawn does), + * so every SPIR/ASPIR lane — precisely the long-running builders this feature + * exists for — has NULL there. Reading `db/schema.ts` shows the column and + * looks fine; the persistence path is where the truth is. + * - Mode is not persisted at all. `resolveMode` computes it at spawn from flags + * plus protocol defaults and discards it, so a spawn-time `--soft` cannot be + * recovered afterwards by recomputation. + * - Harness comes from workspace config, which can change while a builder runs, + * so config is not authoritative for a *running* builder. + * + * So this module reads the worktree, which holds all of it authoritatively: + * porch's `status.yaml` (protocol, phase), `.builder-prompt.txt` (the literal + * `## Mode:` line the builder was given), and `.builder-start.sh` (the launch + * line of the process actually running). + * + * Every chain ends in a **loud abort**, never a default. A guessed protocol or + * mode would produce a plausible-looking re-orientation that quietly reframes the + * builder — the exact drift R3 exists to prevent. + */ + +import { join } from 'node:path'; +import { parseAgentName } from '../../utils/agent-names.js'; +import { + BUILTIN_HARNESSES, + buildCustomHarnessProvider, + type CustomHarnessConfig, + type HarnessProvider, +} from '../../utils/harness.js'; + +// ============================================================================ +// Ports +// ============================================================================ + +/** Filesystem access needed for resolution. Injected so tests need no worktree. */ +export interface ContextFsPort { + exists(path: string): boolean; + read(path: string): string | null; + /** Immediate subdirectory names, or null when the directory does not exist. */ + listDirs(path: string): string[] | null; +} + +// ============================================================================ +// Result +// ============================================================================ + +export interface ResolvedBuilderContext { + builderId: string; + worktree: string; + branch: string; + protocol: string; + /** Where the protocol came from — surfaced in the report so it is auditable. */ + protocolSource: 'status.yaml' | 'builder-id'; + mode: 'strict' | 'soft'; + modeSource: 'flag' | 'builder-prompt'; + harnessName: string; + harness: HarnessProvider; + /** Null for a non-porch lane; that is a branch, not a failure. */ + porch: PorchContext | null; + /** + * Artifact identity, for phase 5's reconstruction of the spawn TemplateContext. + * + * Null on a non-porch lane, where there is no spec/plan naming convention to + * derive from. `specPath`/`planPath` are worktree-relative and are only set + * when the file actually exists — a pointer to a file that is not there would + * send a freshly-reset builder chasing a ghost. + */ + specName: string | null; + specPath: string | null; + planPath: string | null; + /** Issue number, from the registry row or the porch project id. */ + issueNumber?: string; + /** + * Ad-hoc task text, from the registry row (`builders.task_text`). + * + * Present only for `afx spawn --task` builders. Phase 5 needs it to tell that + * lane apart from the others: a task builder still gets a porch project keyed + * on its builder id, so `issueNumber` is populated for it too, and without + * this field the task lane is indistinguishable from an issue-driven one. + */ + taskText?: string; +} + +export interface PorchContext { + projectId: string; + projectName: string; + /** The protocol porch is actually running for this project. */ + protocol: string; + phase: string; + currentPlanPhase: string | null; + statusPath: string; +} + +/** Thrown when a fact cannot be established. Never swallowed into a default. */ +export class ContextResolutionError extends Error { + constructor(message: string) { + super(message); + this.name = 'ContextResolutionError'; + } +} + +// ============================================================================ +// porch status.yaml +// ============================================================================ + +/** + * Locate and parse the worktree's porch status file. + * + * Returns null when there is no porch project — a task or shell builder is a + * legitimate reset target, it simply gets no porch re-entry block. + * + * Parsed with targeted line matching rather than a YAML dependency: this module + * needs four scalar fields, and the file is machine-written by porch. + */ +export function readPorchContext(fs: ContextFsPort, worktree: string): PorchContext | null { + const projectsDir = join(worktree, 'codev', 'projects'); + const dirs = fs.listDirs(projectsDir); + if (!dirs || dirs.length === 0) return null; + + for (const dir of dirs) { + const statusPath = join(projectsDir, dir, 'status.yaml'); + const content = fs.read(statusPath); + if (content === null) continue; + + const protocol = matchScalar(content, 'protocol'); + const phase = matchScalar(content, 'phase'); + const id = matchScalar(content, 'id'); + if (!protocol || !phase) continue; + + const currentPlanPhase = matchScalar(content, 'current_plan_phase'); + return { + projectId: id ?? dir.split('-')[0], + projectName: dir, + protocol, + phase, + currentPlanPhase: currentPlanPhase && currentPlanPhase !== 'null' ? currentPlanPhase : null, + statusPath, + }; + } + + return null; +} + +/** Read a top-level scalar from porch's status.yaml, stripping quotes. */ +function matchScalar(content: string, key: string): string | null { + const m = content.match(new RegExp(`^${key}:\\s*(.+)$`, 'm')); + if (!m) return null; + const raw = m[1].trim().replace(/^['"]|['"]$/g, ''); + return raw === '' ? null : raw; +} + +/** + * The protocol recorded in status.yaml, if there is one. + * + * Reads through `readPorchContext` rather than re-scanning the project dirs, so + * the two cannot disagree about which status.yaml is authoritative when a + * worktree somehow holds more than one project directory. + */ +export function protocolFromStatus(fs: ContextFsPort, worktree: string): string | null { + return readPorchContext(fs, worktree)?.protocol ?? null; +} + +// ============================================================================ +// Mode +// ============================================================================ + +/** + * Read the mode the builder was actually told it was running in. + * + * The spawn prompt renders a literal `## Mode: STRICT` heading, and `--resume` + * does not rewrite the prompt file, so this stays correct across resumes. It is + * a stronger source than recomputing `resolveMode`, which cannot recover a + * spawn-time `--soft` from protocol defaults. + */ +export function modeFromBuilderPrompt(fs: ContextFsPort, worktree: string): 'strict' | 'soft' | null { + const content = fs.read(join(worktree, '.builder-prompt.txt')); + if (content === null) return null; + const m = content.match(/^##\s*Mode:\s*(STRICT|SOFT)\s*$/im); + if (!m) return null; + return m[1].toLowerCase() as 'strict' | 'soft'; +} + +// ============================================================================ +// Harness +// ============================================================================ + +/** + * Identify the harness from the worktree's launch script. + * + * Per-builder ground truth: the script is what the running process was started + * with, so it stays right even if workspace config changed since the spawn. + */ +export function harnessFromLaunchScript( + fs: ContextFsPort, + worktree: string, + customHarnesses?: Record<string, CustomHarnessConfig>, +): string | null { + const content = fs.read(join(worktree, '.builder-start.sh')); + if (content === null) return null; + + // Custom names first: a project may define a custom harness whose name + // contains a builtin's (e.g. "claude-experimental"), and the more specific + // match is the right one. + const names = new Set([ + ...Object.keys(customHarnesses ?? {}), + ...Object.keys(BUILTIN_HARNESSES), + ]); + + for (const line of content.split('\n')) { + const command = commandNameOf(line); + if (command && names.has(command)) return command; + } + return null; +} + +/** + * The command a shell line invokes, or null if the line invokes nothing. + * + * Matching on **command position** rather than searching the whole line for a + * harness name: a substring search would report a false positive on a line like + * `if [ "$HARNESS" = "codex" ]`, and naming the wrong harness would either refuse + * a resettable builder or — worse — approve typing into one that cannot be reset. + */ +function commandNameOf(line: string): string | null { + let rest = line.trim(); + if (!rest || rest.startsWith('#')) return null; + + // Strip shell control keywords that can prefix a command on the same line. + rest = rest.replace(/^(?:while|until|if|then|else|elif|do|done|fi|exec|command|nohup)\s+/, ''); + // Strip leading `VAR=value ` environment assignments. + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(rest)) { + const space = rest.indexOf(' '); + if (space === -1) return null; // a bare assignment invokes nothing + rest = rest.slice(space + 1).trimStart(); + } + + const first = rest.split(/\s+/)[0]; + if (!first || first.includes('=')) return null; + + // Basename, so `/usr/local/bin/claude` resolves like `claude`, with any + // surrounding quotes removed. + const base = first.replace(/^['"]|['"]$/g, '').split('/').pop(); + return base && base !== '' ? base : null; +} + +/** + * Map a harness name to its provider, honouring project-defined custom harnesses. + * + * Custom harnesses resolve to a real provider rather than failing as + * "unrecognisable", so the refusal a project sees is the accurate one — *this + * harness cannot reset in-session* — instead of a misleading "unknown harness". + * `buildCustomHarnessProvider` does not set `supportsContextReset`, so custom + * harnesses are unsupported by default; letting one declare support would be a + * one-field addition to `CustomHarnessConfig`, deliberately out of scope here. + */ +export function harnessProviderFor( + harnessName: string, + customHarnesses?: Record<string, CustomHarnessConfig>, +): HarnessProvider | null { + const builtin = BUILTIN_HARNESSES[harnessName]; + if (builtin) return builtin; + if (customHarnesses && harnessName in customHarnesses) { + return buildCustomHarnessProvider(customHarnesses[harnessName]); + } + return null; +} + +/** + * Derive artifact identity from the porch project. + * + * Porch names its project dir `<id>-<title>`, and spec/plan files share that + * stem by convention (`codev/specs/<id>-<title>.md`). Paths are returned only + * when the file exists. + */ +export function artifactPaths( + fs: ContextFsPort, + worktree: string, + porch: PorchContext | null, +): { specName: string | null; specPath: string | null; planPath: string | null } { + if (!porch) return { specName: null, specPath: null, planPath: null }; + + const specName = porch.projectName; + const specRel = join('codev', 'specs', `${specName}.md`); + const planRel = join('codev', 'plans', `${specName}.md`); + + return { + specName, + specPath: fs.exists(join(worktree, specRel)) ? specRel : null, + planPath: fs.exists(join(worktree, planRel)) ? planRel : null, + }; +} + +// ============================================================================ +// Resolution +// ============================================================================ + +export interface ResolveContextOptions { + fs: ContextFsPort; + builderId: string; + worktree: string; + branch: string; + issueNumber?: string; + /** `builders.task_text`, for `--task` builders. Forwarded verbatim. */ + taskText?: string; + /** `--mode` override; wins over the worktree, for when the prompt file is gone. */ + modeOverride?: 'strict' | 'soft'; + /** Project-defined harnesses from `.codev/config.json`, if any. */ + customHarnesses?: Record<string, CustomHarnessConfig>; +} + +/** + * Resolve everything the re-orientation needs, or throw. + * + * Deliberately does NOT consult `builders.protocol_name`: it is NULL for + * spec-type builders, so a reader who "fixed" this to use the registry would + * break exactly the lanes the feature targets while the code looked more correct. + */ +export function resolveBuilderContext(options: ResolveContextOptions): ResolvedBuilderContext { + const { fs, builderId, worktree, branch, issueNumber, taskText, modeOverride, customHarnesses } = + options; + + if (!fs.exists(worktree)) { + throw new ContextResolutionError( + `Worktree not found for '${builderId}': ${worktree}. The builder's registry row may be stale.`, + ); + } + + // --- Protocol: status.yaml → builder id → abort ------------------------- + let protocol = protocolFromStatus(fs, worktree); + let protocolSource: ResolvedBuilderContext['protocolSource'] = 'status.yaml'; + if (!protocol) { + const parsed = parseAgentName(builderId); + if (parsed) { + protocol = parsed.protocol; + protocolSource = 'builder-id'; + } + } + if (!protocol) { + throw new ContextResolutionError( + `Cannot determine the protocol for '${builderId}': no porch status.yaml under ${worktree}/codev/projects, ` + + `and the builder id is not in canonical 'builder-<protocol>-<id>' form. ` + + `Refusing to re-orient a builder without naming its protocol.`, + ); + } + + // --- Mode: flag → .builder-prompt.txt → abort --------------------------- + let mode = modeOverride ?? null; + let modeSource: ResolvedBuilderContext['modeSource'] = 'flag'; + if (!mode) { + mode = modeFromBuilderPrompt(fs, worktree); + modeSource = 'builder-prompt'; + } + if (!mode) { + throw new ContextResolutionError( + `Cannot determine the mode (strict/soft) for '${builderId}': no '## Mode:' line in ` + + `${join(worktree, '.builder-prompt.txt')}. Mode is not persisted anywhere else — ` + + `pass --mode strict or --mode soft explicitly.`, + ); + } + + // --- Harness: .builder-start.sh → provider → capability check → abort --- + const harnessName = harnessFromLaunchScript(fs, worktree, customHarnesses); + if (!harnessName) { + throw new ContextResolutionError( + `Cannot determine the harness for '${builderId}': no recognisable launch command in ` + + `${join(worktree, '.builder-start.sh')}. Refusing to type into a terminal whose agent is unknown.`, + ); + } + const harness = harnessProviderFor(harnessName, customHarnesses); + if (!harness) { + throw new ContextResolutionError( + `Harness '${harnessName}' is not a known provider — it is neither built in nor defined under ` + + `"harness" in .codev/config.json. Refusing to type into a terminal whose agent is unknown.`, + ); + } + if (!harness.supportsContextReset) { + throw new ContextResolutionError( + `Harness '${harnessName}' has no in-session context reset, so 'afx reset' cannot clear this ` + + `builder's context. Only the claude harness supports it today. ` + + `To give this builder a fresh window, stop it and respawn without --resume.`, + ); + } + + const porch = readPorchContext(fs, worktree); + const { specName, specPath, planPath } = artifactPaths(fs, worktree, porch); + + return { + builderId, + worktree, + branch, + protocol, + protocolSource, + mode, + modeSource, + harnessName, + harness, + porch, + specName, + specPath, + planPath, + // Issue-driven protocols name the porch project after the issue, so the + // project id is the correct value when the registry row has none. + issueNumber: issueNumber ?? porch?.projectId, + taskText, + }; +} diff --git a/packages/codev/src/agent-farm/commands/reset/index.ts b/packages/codev/src/agent-farm/commands/reset/index.ts new file mode 100644 index 000000000..0117fe675 --- /dev/null +++ b/packages/codev/src/agent-farm/commands/reset/index.ts @@ -0,0 +1,777 @@ +/** + * Reset orchestrator — the `afx reset` state machine (Spec 1273). + * + * Composes the verified parts from phases 1–5 into the flow the architect ran by + * hand on 2026-07-27: request save-state → verify the receipt → wait for the + * turn to end → `/clear` → re-orient. + * + * ## Why this is a state machine over injected ports + * + * Reset is destructive in a way almost nothing else in Agent Farm is: `/clear` + * discards a builder's entire conversation, and there is no undo. Every safety + * property is therefore an ORDERING property — "the clear never happens before + * X" — and ordering is exactly what is hardest to prove by reading code. + * + * So the orchestrator does two things no ordinary command does: + * + * 1. Every externally-visible action goes through a port (`clock`, `fs`, + * `sendMessage`, `sendRaw`, `sendEscape`). Nothing here touches Tower, a + * PTY or the filesystem directly. + * 2. Every action is appended to an ordered **step log** before it is + * performed. + * + * The invariant tests then assert over that log — that `clear` never appears + * without `assemble` before it, that `escalate-esc` never precedes + * `receipt-accepted`, that an aborted run contains no `clear` at all. That turns + * "impossible by construction" from a claim in a comment into something a test + * can fail on. + * + * ## The invariants, and where each is enforced + * + * - **R1** (never clear without a saved re-orientation): `runReset` assembles and + * writes `.builder-reorient.md` at step 2, before any destructive step exists + * in the log. Assembly failure returns before the request is even sent. + * - **R2** (never clear without a verified receipt): the poll loop only leaves + * `awaiting-receipt` on `accepted`; every other exit path returns an aborted + * result. + * - **R3** (complete-or-abort re-orientation): delegated wholesale to + * `assembleReorientation`, which throws rather than emitting a partial frame. + * - **R4** (never clear a builder mid-turn): quiescence is a bounded wait, then + * at most ONE ESC escalation, then a second bounded wait. Still not quiet + * aborts — there is deliberately no third attempt and no "clear anyway". + */ + +import type { + ResolvedBuilderContext, +} from './context.js'; +import { + assembleReorientation, + ReorientationAssemblyError, + type IssuePayload, + type ReorientationPayload, + type ResumeNoticePort, + type SpawnPromptPort, +} from './reorient.js'; +import { + buildSaveRequest, + describeReceiptFailure, + generateNonce, + stateFilePath, + verifyReceipt, + type ReceiptFsPort, + type ReceiptObservation, +} from './receipt.js'; +import { + DEFAULT_MIN_BYTES, + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_QUIESCE_POST_ESCALATION_TIMEOUT_MS, + DEFAULT_QUIESCE_TIMEOUT_MS, + DEFAULT_QUIET_WINDOW_MS, + DEFAULT_RECEIPT_TIMEOUT_MS, + DEFAULT_STABILITY_WINDOW_MS, + REORIENT_FILE_NAME, +} from './constants.js'; + +// ============================================================================ +// Ports +// ============================================================================ + +/** Filesystem surface. Superset of the receipt gate's, plus the R1 write. */ +export interface ResetFsPort extends ReceiptFsPort { + write(path: string, content: string): void; +} + +/** Wall clock and sleep, injected so tests run instantly and deterministically. */ +export interface ClockPort { + now(): number; + sleep(ms: number): Promise<void>; +} + +/** What the orchestrator can observe about the target terminal. */ +export interface TerminalObservation { + exists: boolean; + /** + * Epoch ms of the last PTY output (phase 2). + * + * `undefined` means the Tower serving this terminal predates the field — NOT + * "no output ever". The quiescence gate refuses to proceed on `undefined` + * rather than treating it as 0, which would read as "silent since 1970" and + * clear a builder mid-turn. That is the exact R4 violation this field exists + * to prevent. + */ + lastDataAt?: number; + /** + * Whether input can actually reach the process right now. + * + * `undefined` means an older Tower did not report it. Unlike `lastDataAt`, + * absence here is NOT treated as a refusal, and the asymmetry is deliberate: + * an unobservable turn state leads to a SILENT, destructive failure (clearing + * a builder mid-turn), whereas an unobservable write path leads to a LOUD, + * harmless one (the first send throws). Refuse only what fails quietly. + */ + writable?: boolean; +} + +/** + * The three ways to reach a terminal, kept as three distinct operations. + * + * They are separate methods rather than one `write(data, mode)` because the + * underlying transports are genuinely different and confusing two of them is + * silent. Tower's `escape` route calls `writeEscapeToSession`, which writes a + * hardcoded ESC and **ignores the message body entirely** + * (`servers/message-write.ts:46`). So a single `writeRaw(data)` bound to + * `escape: true` would deliver an ESC when asked to type `/clear` — the command + * would appear to succeed, the terminal would go quiet, and no context would be + * cleared. Splitting the port makes that mistake unrepresentable. + */ +export interface TerminalPort { + observe(): Promise<TerminalObservation>; + /** Deliver a normal, formatted message (the save request, the re-orientation). */ + sendMessage(message: string): Promise<void>; + /** + * Type unformatted text into the PTY (Tower's `raw: true`). + * + * This is how `/clear` is delivered — as literal typed input, which is what + * the verified manual recipe used (`afx send <builder> --raw '/clear'`). + */ + sendRaw(text: string): Promise<void>; + /** Send a bare ESC keystroke (Tower's `escape: true`; phase 1's path). */ + sendEscape(): Promise<void>; + /** + * Recent terminal output plus the buffer's total line count, for best-effort + * clear confirmation. Null when this Tower cannot serve it. + * + * `total` is what makes confirmation trustworthy. Reset writes into this same + * terminal — the save request, the re-orientation — so any pattern matched + * against the whole buffer eventually collides with reset's OWN text. (It did, + * twice: first the echoed `/clear`, then the save request's "CONTEXT RESET + * INCOMING" header.) Snapshotting `total` before the clear lets the check read + * only what the harness emitted AFTERWARDS. + */ + readOutput?(): Promise<{ lines: string[]; total: number } | null>; +} + +// ============================================================================ +// Step log +// ============================================================================ + +/** + * Every externally-visible action, in order. + * + * The names are part of the test contract — the invariant assertions match on + * them — so renaming one is an API change, not a cosmetic edit. + */ +export type ResetStepName = + | 'resolve' + | 'assemble' + | 'write-reorient-file' + | 'interrupt-first' + | 'send-save-request' + | 'receipt-accepted' + | 'quiescent' + | 'escalate-esc' + | 'clear' + | 'clear-confirmed' + | 'clear-unconfirmed' + | 'send-reorientation'; + +export interface ResetStep { + name: ResetStepName; + at: number; + detail?: string; +} + +export type ResetOutcome = 'completed' | 'aborted' | 'dry-run'; + +export interface ResetResult { + outcome: ResetOutcome; + steps: ResetStep[]; + /** Populated on `aborted`. Names the gate that failed, never just "failed". */ + abortReason?: string; + nonce: string; + statePath: string; + reorientPath: string; + payload?: ReorientationPayload; + /** + * The exact save-state request the builder is asked to act on. + * + * Exposed rather than kept internal because `--dry-run` must be able to print + * it. The request is what the whole R2 gate is verifying compliance with, so + * "what exactly will the builder be told to write" is the single most useful + * thing a dry run can show — and it cannot be shown if the orchestrator keeps + * it to itself. + */ + saveRequest: string; + /** Size of the accepted state file, for the report. */ + stateBytes?: number; +} + +/** Thrown for conditions that must stop the run before anything is touched. */ +export class ResetPreflightError extends Error { + constructor(message: string) { + super(message); + this.name = 'ResetPreflightError'; + } +} + +// ============================================================================ +// Options +// ============================================================================ + +export interface RunResetOptions { + context: ResolvedBuilderContext; + fs: ResetFsPort; + clock: ClockPort; + terminal: TerminalPort; + buildSpawnPrompt: SpawnPromptPort; + buildResumeNotice?: ResumeNoticePort; + issue?: IssuePayload; + /** Architect addendum from `--note` / `--file`. */ + addendum?: string; + /** Print the plan and touch nothing. */ + dryRun?: boolean; + /** ESC before the save request, for a builder already wedged mid-turn. */ + interruptFirst?: boolean; + receiptTimeoutMs?: number; + pollIntervalMs?: number; + minBytes?: number; + stabilityWindowMs?: number; + quietWindowMs?: number; + quiesceTimeoutMs?: number; + quiescePostEscalationTimeoutMs?: number; + /** Override for the state-file name. Validated to stay inside the worktree. */ + stateFileName?: string; +} + +// ============================================================================ +// Orchestration +// ============================================================================ + +export async function runReset(options: RunResetOptions): Promise<ResetResult> { + const { + context, + fs, + clock, + terminal, + buildSpawnPrompt, + buildResumeNotice, + issue, + addendum, + dryRun = false, + interruptFirst = false, + receiptTimeoutMs = DEFAULT_RECEIPT_TIMEOUT_MS, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + minBytes = DEFAULT_MIN_BYTES, + stabilityWindowMs = DEFAULT_STABILITY_WINDOW_MS, + quietWindowMs = DEFAULT_QUIET_WINDOW_MS, + quiesceTimeoutMs = DEFAULT_QUIESCE_TIMEOUT_MS, + quiescePostEscalationTimeoutMs = DEFAULT_QUIESCE_POST_ESCALATION_TIMEOUT_MS, + stateFileName, + } = options; + + const steps: ResetStep[] = []; + const step = (name: ResetStepName, detail?: string) => { + steps.push({ name, at: clock.now(), detail }); + }; + + const statePath = resolveStatePath(context.worktree, stateFileName); + const reorientPath = stateFilePath(context.worktree, REORIENT_FILE_NAME); + const nonce = generateNonce(); + + // -------------------------------------------------------------------- + // 0. Timing-parameter sanity. + // + // Validated HERE as well as at the CLI boundary, because these values do not + // merely tune the run — each one can switch OFF a safety gate while the run + // still reports success. A negative quiet window makes every quiescence check + // pass instantly (R4 gone); a non-positive minimum accepts any state file + // however empty (R2's substance floor gone); a NaN timeout produces a NaN + // deadline, and since every comparison against NaN is false, the wait never + // expires and the command hangs. + // + // The orchestrator is the component that OWNS these invariants, so it does + // not delegate their preconditions to its callers. A programmatic caller must + // not be able to disable R2 or R4 by passing a number. + // -------------------------------------------------------------------- + requirePositive(receiptTimeoutMs, 'receiptTimeoutMs'); + requirePositive(pollIntervalMs, 'pollIntervalMs'); + requirePositive(minBytes, 'minBytes'); + requirePositive(stabilityWindowMs, 'stabilityWindowMs'); + requirePositive(quietWindowMs, 'quietWindowMs'); + requirePositive(quiesceTimeoutMs, 'quiesceTimeoutMs'); + requirePositive(quiescePostEscalationTimeoutMs, 'quiescePostEscalationTimeoutMs'); + + // -------------------------------------------------------------------- + // 1. Preflight. Everything that can refuse does so before ANY write. + // -------------------------------------------------------------------- + + // Harness capability is checked first and aborts loudly, naming the harness. + // No substituted mechanism: if this agent has no in-session context reset, + // there is no partial version of a reset worth doing (fail fast). + if (!context.harness.supportsContextReset) { + throw new ResetPreflightError( + `Builder '${context.builderId}' runs under the '${context.harnessName}' harness, ` + + `which has no in-session context reset. Reset is a no-op there — there is no ` + + `substitute mechanism. Use the boundary-recycle pattern instead: let the builder ` + + `finish, then respawn with 'afx spawn <id> --resume'.`, + ); + } + + const observed = await terminal.observe(); + if (!observed.exists) { + throw new ResetPreflightError( + `Builder '${context.builderId}' has no live terminal. Reset writes to a running ` + + `session; there is nothing to clear. Check 'afx status'.`, + ); + } + + // Writability is checked HERE, in preflight, not left to fail on the first + // send. The plan's contract is validate-before-touch: discovering the problem + // later would mean `.builder-reorient.md` had already been written to the + // builder's worktree for a reset that could never proceed. + // + // `status: 'running'` is not sufficient evidence — a session whose shellper + // connection died reports exactly that while dropping every write (#1198), + // which is the specific case this catches. + if (observed.writable === false) { + throw new ResetPreflightError( + `Builder '${context.builderId}' has a terminal that is not accepting input ` + + `(its process connection is down — #1198). Reset would send a save request that ` + + `is silently dropped. Nothing has been touched. Check Tower logs, or retry once ` + + `the session reconnects.`, + ); + } + step('resolve', `${context.protocol}/${context.mode} in ${context.worktree}`); + + // -------------------------------------------------------------------- + // 2. Assemble + persist the re-orientation. THIS IS R1's ENFORCEMENT POINT. + // + // Everything destructive is downstream of here. A throw from assembly (R3) + // propagates with the builder completely untouched — no message sent, no + // keystroke written. + // -------------------------------------------------------------------- + let payload: ReorientationPayload; + try { + payload = assembleReorientation({ + context, + statePath, + addendum, + buildSpawnPrompt, + buildResumeNotice, + issue, + }); + } catch (err) { + if (err instanceof ReorientationAssemblyError) { + throw new ResetPreflightError( + `Refusing to reset: ${err.message} The builder has not been touched.`, + ); + } + throw err; + } + step('assemble', `${payload.inline.split('\n').length} inline lines`); + + const saveRequest = buildSaveRequest(nonce, statePath); + + // A dry run stops here, having proved the two things worth proving before a + // real run: that assembly succeeds (R3) and what the builder would receive. + // Zero writes reach the builder, which is what makes R1 auditable by + // inspection rather than by trust. + if (dryRun) { + return { + outcome: 'dry-run', + steps, + nonce, + statePath, + reorientPath, + payload, + saveRequest, + }; + } + + fs.write(reorientPath, payload.longForm); + step('write-reorient-file', reorientPath); + + // -------------------------------------------------------------------- + // 3. Optional pre-emptive interrupt. + // + // Default OFF. A builder that is reachable should be ASKED to save, not + // interrupted first — an ESC into a working builder costs it the turn it was + // mid-way through. This is opt-in for the case the architect already knows + // about: a builder wedged on a foreground wait, where every queued message + // including the save request would go unread until the turn ends. + // -------------------------------------------------------------------- + if (interruptFirst) { + await terminal.sendEscape(); + step('interrupt-first'); + } + + // -------------------------------------------------------------------- + // 4–5. Request the save, then gate on a VERIFIED receipt (R2). + // -------------------------------------------------------------------- + await terminal.sendMessage(saveRequest); + step('send-save-request', `nonce ${nonce}`); + + const receipt = await awaitReceipt({ + fs, + clock, + statePath, + nonce, + minBytes, + stabilityWindowMs, + pollIntervalMs, + timeoutMs: receiptTimeoutMs, + }); + + if (receipt.status !== 'accepted') { + // Abort with the builder's context fully intact. The state file may exist + // and be perfectly good — it simply was not PROVEN good, and an unproven + // save is not a basis for discarding a conversation. + return { + outcome: 'aborted', + steps, + abortReason: `Save-state receipt not verified. ${describeReceiptFailure(receipt, statePath, minBytes)}`, + nonce, + statePath, + reorientPath, + payload, + saveRequest, + }; + } + step('receipt-accepted', `${receipt.bytes} bytes`); + + // -------------------------------------------------------------------- + // 6. Quiescence (R4). + // + // The receipt proves the file is written; it does NOT prove the turn ended. + // A builder that wrote the file and kept working would receive `/clear` as + // literal text mid-turn — the keystroke would land in its input buffer rather + // than executing. Hence a real silence check. + // -------------------------------------------------------------------- + let quiet = await awaitQuiescence({ + terminal, + clock, + quietWindowMs, + timeoutMs: quiesceTimeoutMs, + pollIntervalMs, + }); + + if (!quiet.quiet) { + if (quiet.reason === 'terminal-gone') { + return { + outcome: 'aborted', + steps, + abortReason: + `Builder '${context.builderId}' lost its terminal while waiting for its turn to end. ` + + `Nothing was cleared. Its saved state is at ${statePath} — that file survives the ` + + `terminal, so respawn with 'afx spawn <id> --resume' and point the new session at it.`, + nonce, + statePath, + reorientPath, + payload, + saveRequest, + stateBytes: receipt.bytes, + }; + } + + if (quiet.reason === 'unobservable') { + return { + outcome: 'aborted', + steps, + abortReason: + `Cannot observe terminal quiescence: this Tower does not report 'lastDataAt' ` + + `(Spec 1273 / phase 2). Refusing to clear a builder whose turn state is unknown — ` + + `treating "unknown" as "idle" is exactly how a mid-turn clear happens. ` + + `Restart Tower on a current build.`, + nonce, + statePath, + reorientPath, + payload, + saveRequest, + stateBytes: receipt.bytes, + }; + } + + // EXACTLY ONE escalation, and it is legal only here — after the R2 receipt. + // Before the receipt an ESC could interrupt the very save being requested; + // after it, the worst case is ending a turn whose valuable output is already + // on disk. + await terminal.sendEscape(); + step('escalate-esc'); + + quiet = await awaitQuiescence({ + terminal, + clock, + quietWindowMs, + timeoutMs: quiescePostEscalationTimeoutMs, + pollIntervalMs, + }); + + if (!quiet.quiet) { + // No third attempt, no "clear anyway". Aborting leaves a builder with its + // context and a saved state file — recoverable. Clearing a busy builder + // does not. + return { + outcome: 'aborted', + steps, + abortReason: + `Builder did not go quiet within ${quiescePostEscalationTimeoutMs}ms of the ESC ` + + `escalation. Refusing to clear a builder that is still mid-turn (R4). Its state ` + + `file is saved at ${statePath}; retry once it settles, or raise --quiet-window.`, + nonce, + statePath, + reorientPath, + payload, + saveRequest, + stateBytes: receipt.bytes, + }; + } + } + step('quiescent'); + + // -------------------------------------------------------------------- + // 7–9. Clear, confirm best-effort, re-orient. + // -------------------------------------------------------------------- + // Snapshot the buffer size BEFORE clearing, so confirmation can distinguish + // the harness's response from the echo of our own keystroke. + const totalBeforeClear = terminal.readOutput + ? ((await terminal.readOutput())?.total ?? 0) + : 0; + + await terminal.sendRaw('/clear'); + step('clear'); + + const confirmed = await confirmClear(terminal, totalBeforeClear); + step(confirmed ? 'clear-confirmed' : 'clear-unconfirmed'); + + await terminal.sendMessage(payload.inline); + step('send-reorientation'); + + return { + outcome: 'completed', + steps, + nonce, + statePath, + reorientPath, + payload, + saveRequest, + stateBytes: receipt.bytes, + }; +} + +// ============================================================================ +// Gates +// ============================================================================ + +interface AwaitReceiptOptions { + fs: ReceiptFsPort; + clock: ClockPort; + statePath: string; + nonce: string; + minBytes: number; + stabilityWindowMs: number; + pollIntervalMs: number; + timeoutMs: number; +} + +/** + * Poll the state file until it is accepted or the wait expires. + * + * Returns the LAST observation rather than a boolean, so the abort message can + * name the specific gate that failed — "wrote a 200-byte stub" and "never wrote + * anything" call for different responses from the architect. + */ +async function awaitReceipt(options: AwaitReceiptOptions): Promise<ReceiptObservation> { + const { fs, clock, statePath, nonce, minBytes, stabilityWindowMs, pollIntervalMs, timeoutMs } = + options; + + const deadline = clock.now() + timeoutMs; + let previous: ReceiptObservation | null = null; + let previousAt = clock.now(); + let latest: ReceiptObservation = { status: 'missing' }; + + for (;;) { + const now = clock.now(); + latest = verifyReceipt({ + fs, + statePath, + nonce, + minBytes, + previous, + msSincePrevious: now - previousAt, + stabilityWindowMs, + }); + + if (latest.status === 'accepted') return latest; + + // Only advance the stability baseline when the gap has actually been + // consumed. Resetting `previousAt` on every poll would make the two + // observations always "too close together" and stability unreachable. + if (now - previousAt >= stabilityWindowMs) { + previous = latest; + previousAt = now; + } + + if (clock.now() >= deadline) return latest; + await clock.sleep(pollIntervalMs); + if (clock.now() >= deadline) { + // Re-evaluate once after the final sleep so a file that landed during it + // is not discarded on a technicality. + const now2 = clock.now(); + latest = verifyReceipt({ + fs, + statePath, + nonce, + minBytes, + previous, + msSincePrevious: now2 - previousAt, + stabilityWindowMs, + }); + return latest; + } + } +} + +interface AwaitQuiescenceOptions { + terminal: TerminalPort; + clock: ClockPort; + quietWindowMs: number; + timeoutMs: number; + pollIntervalMs: number; +} + +type QuiescenceReason = 'timeout' | 'unobservable' | 'terminal-gone'; + +/** + * Wait until the terminal has produced no output for `quietWindowMs`. + * + * `lastDataAt === undefined` is reported as `unobservable`, NOT as quiet. An + * older Tower omits the field, and defaulting it to 0 would compute an age of + * ~56 years and clear a busy builder instantly. + */ +async function awaitQuiescence( + options: AwaitQuiescenceOptions, +): Promise<{ quiet: boolean; reason?: QuiescenceReason }> { + const { terminal, clock, quietWindowMs, timeoutMs, pollIntervalMs } = options; + const deadline = clock.now() + timeoutMs; + + for (;;) { + const observation = await terminal.observe(); + // Checked BEFORE lastDataAt. A terminal that vanished mid-run also reports + // no lastDataAt, and conflating the two sends the architect to check their + // Tower version when the real event is that the builder's terminal died. + // The two need different responses, so they get different reasons. + if (!observation.exists) { + return { quiet: false, reason: 'terminal-gone' }; + } + if (observation.lastDataAt === undefined) { + return { quiet: false, reason: 'unobservable' }; + } + + if (clock.now() - observation.lastDataAt >= quietWindowMs) { + return { quiet: true }; + } + + if (clock.now() >= deadline) return { quiet: false, reason: 'timeout' }; + await clock.sleep(pollIntervalMs); + if (clock.now() >= deadline) { + const final = await terminal.observe(); + if (!final.exists) return { quiet: false, reason: 'terminal-gone' }; + if (final.lastDataAt === undefined) return { quiet: false, reason: 'unobservable' }; + if (clock.now() - final.lastDataAt >= quietWindowMs) return { quiet: true }; + return { quiet: false, reason: 'timeout' }; + } + } +} + +/** + * Best-effort check that `/clear` took effect. + * + * Deliberately advisory. There is no reliable cross-version signal that Claude + * Code cleared its context, and an unconfirmed clear is reported as unconfirmed + * rather than as failure — the re-orientation that follows is correct either + * way. The worst case of a silent no-op is a builder that kept its context and + * also received a re-orientation, which loses nothing. + */ +async function confirmClear(terminal: TerminalPort, totalBeforeClear: number): Promise<boolean> { + if (!terminal.readOutput) return false; + try { + const output = await terminal.readOutput(); + if (!output) return false; + + // Consider ONLY lines produced after the clear was sent. Everything reset + // itself wrote is at or before `totalBeforeClear`, so this excludes reset's + // own text by construction rather than by hoping the pattern avoids it. + const newLineCount = output.total - totalBeforeClear; + if (newLineCount <= 0) return false; + const fresh = output.lines.slice(-newLineCount).join('\n'); + + return /context (?:cleared|reset)|conversation (?:cleared|reset)|cleared conversation/i.test( + fresh, + ); + } catch { + // Confirmation must never be able to fail the run — it is a report field. + return false; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Reject a timing/threshold parameter that would weaken a gate. + * + * `Number.isFinite` rather than a bare `> 0` comparison: `NaN > 0` is false but + * so is `NaN <= 0`, so a NaN slips through any single comparison written the + * obvious way. Infinity is rejected too — an infinite deadline is a hang. + */ +function requirePositive(value: number, name: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new ResetPreflightError( + `Invalid ${name}: ${value}. Must be a positive, finite number. ` + + `This parameter gates a safety check (R2/R4) — a bad value would disable it silently.`, + ); + } +} + +/** + * Resolve the state-file path, refusing anything that escapes the worktree. + * + * The path is handed to the builder as a write instruction, so an override of + * `../../../etc/whatever` would have the builder write outside its own worktree. + * Containment is checked on the resolved path, not the raw string, so `..` + * segments cannot slip through. + */ +function resolveStatePath(worktree: string, stateFileName?: string): string { + const candidate = stateFilePath(worktree, stateFileName); + if (!stateFileName) return candidate; + + const normalizedWorktree = worktree.endsWith('/') ? worktree : `${worktree}/`; + if (!candidate.startsWith(normalizedWorktree)) { + throw new ResetPreflightError( + `State-file override '${stateFileName}' resolves to ${candidate}, outside the builder's ` + + `worktree (${worktree}). Refusing: the path is sent to the builder as a write instruction.`, + ); + } + return candidate; +} + +/** + * Render the step log as the run report. + * + * Each line carries its EVIDENCE, not just a tick. "Receipt accepted" alone is + * the kind of reassurance that hides a stale file; "accepted — 8,432 bytes + * carrying nonce a1b2c3" is checkable. + */ +export function formatResetReport(result: ResetResult): string { + const lines: string[] = []; + for (const s of result.steps) { + lines.push(s.detail ? ` ✓ ${s.name} — ${s.detail}` : ` ✓ ${s.name}`); + } + if (result.outcome === 'aborted') { + lines.push(''); + lines.push(` ✗ ABORTED: ${result.abortReason}`); + lines.push(' The builder\'s context was NOT cleared.'); + } + return lines.join('\n'); +} diff --git a/packages/codev/src/agent-farm/commands/reset/receipt.ts b/packages/codev/src/agent-farm/commands/reset/receipt.ts new file mode 100644 index 000000000..1da7ed78c --- /dev/null +++ b/packages/codev/src/agent-farm/commands/reset/receipt.ts @@ -0,0 +1,218 @@ +/** + * The reset receipt gate — invariant R2 (Spec 1273). + * + * `afx reset` clears a builder's context, which is irreversible. The manual + * version of this flow guarded that step by eyeballing the state file ("ours was + * 203 lines"). This module replaces the eyeball with evidence: a state file is + * accepted only if it proves it is *this run's* save, is substantive, and has + * stopped growing. + * + * Freshness comes from a nonce written INSIDE the file, not from mtime. mtime + * was rejected deliberately: filesystem timestamp granularity and clock skew + * make it fragile, and it cannot distinguish "rewritten in response to this + * request" from "touched". A nonce the builder must reproduce can only appear + * in a file written after the request that carried it. + * + * Pure: all filesystem access arrives through injected ports, so every rejection + * path is testable without a real builder, a real worktree, or real time. + */ + +import { randomBytes } from 'node:crypto'; +import { join } from 'node:path'; +import { + DEFAULT_MIN_BYTES, + DEFAULT_STABILITY_WINDOW_MS, + STATE_FILE_NAME, +} from './constants.js'; + +// ============================================================================ +// Ports +// ============================================================================ + +/** Filesystem access needed by the gate. Injected so tests need no real files. */ +export interface ReceiptFsPort { + /** Byte length of the file, or null when it does not exist. */ + sizeOf(path: string): number | null; + /** Full contents, or null when the file does not exist. */ + read(path: string): string | null; +} + +// ============================================================================ +// Nonce +// ============================================================================ + +/** + * Generate a per-run nonce. + * + * Short enough that a builder reproduces it without transcription errors, long + * enough that a stale file from a previous reset cannot collide with it. + */ +export function generateNonce(): string { + return randomBytes(6).toString('hex'); +} + +/** + * The marker line the builder is asked to reproduce verbatim. + * + * An HTML comment so it is invisible in rendered markdown but trivially + * greppable, and harmless if the file is later read by a human. + */ +export function nonceMarker(nonce: string): string { + return `<!-- codev-reset: ${nonce} -->`; +} + +// ============================================================================ +// The save-state request +// ============================================================================ + +/** + * Build the message asking the builder to write its working state. + * + * The checklist is the quality bar. It cannot be enforced programmatically (see + * `verifyReceipt` — the gate is structural on purpose), so it has to be explicit + * enough that a builder writing in good faith produces something a cold reader + * can actually use. + */ +export function buildSaveRequest(nonce: string, statePath: string): string { + return [ + 'CONTEXT RESET INCOMING — save your working state now.', + '', + `Write your complete working state to \`${statePath}\` (untracked; do not stage or commit it).`, + '', + `The file MUST begin with this exact line, reproduced character for character:`, + '', + nonceMarker(nonce), + '', + 'Everything after that line is yours. Write it **for a cold reader** — a competent', + 'agent who wakes up with your worktree, your branch, and no memory of this', + 'conversation. Assume nothing carries over. Cover:', + '', + '1. **Role and mission** — what you are building and why.', + '2. **Position in the protocol** — phase, plan phase, what porch expects next.', + '3. **Receipts** — what is actually done and verified, with file paths and commit', + ' hashes. Distinguish "written" from "verified"; a cold reader cannot tell.', + '4. **In-flight work** — anything started but not finished, and where it stands.', + '5. **Open questions** — decisions you deferred, and what they hinge on.', + '6. **Standing orders** — instructions from the architect you are still bound by,', + ' including anything you were told NOT to do.', + '7. **Next concrete action** — the single thing to do first after the reset.', + '', + 'Do not summarise for brevity. A save that omits a standing order or a receipt', + 'costs more than a long file does. When the file is written, stop and wait.', + ].join('\n'); +} + +// ============================================================================ +// Verification +// ============================================================================ + +/** Why a candidate state file was not accepted, or that it was. */ +export type ReceiptStatus = + | 'accepted' + | 'missing' + | 'wrong-nonce' + | 'too-small' + | 'still-growing'; + +export interface ReceiptObservation { + status: ReceiptStatus; + /** Size at the moment of observation, when the file existed. */ + bytes?: number; +} + +export interface VerifyReceiptOptions { + fs: ReceiptFsPort; + statePath: string; + nonce: string; + minBytes?: number; + /** A previous observation, for the stability comparison. */ + previous?: ReceiptObservation | null; + /** ms since `previous` was taken. Stability needs a real gap, not two reads in a row. */ + msSincePrevious?: number; + stabilityWindowMs?: number; +} + +/** + * Evaluate the state file once. + * + * Checks run in escalating order so the reported reason is the *most specific* + * one true: existence, then freshness, then substance, then stability. A caller + * polls this and only proceeds on `accepted`. + * + * Stability requires agreement between two observations separated by at least + * `stabilityWindowMs`. Without the time gap, a builder mid-write would look + * stable simply because two reads landed between the same two `write()` calls. + */ +export function verifyReceipt(options: VerifyReceiptOptions): ReceiptObservation { + const { + fs, + statePath, + nonce, + minBytes = DEFAULT_MIN_BYTES, + previous = null, + msSincePrevious = 0, + stabilityWindowMs = DEFAULT_STABILITY_WINDOW_MS, + } = options; + + const bytes = fs.sizeOf(statePath); + if (bytes === null) return { status: 'missing' }; + + const content = fs.read(statePath); + if (content === null) return { status: 'missing' }; + + // Match on the nonce token rather than the whole marker line: a builder that + // reproduces the nonce but alters the comment's spacing has still proved + // freshness, and failing that file would discard a real save over whitespace. + if (!content.includes(nonce)) return { status: 'wrong-nonce', bytes }; + + if (bytes < minBytes) return { status: 'too-small', bytes }; + + const stable = + previous !== null && + previous.bytes === bytes && + (previous.status === 'still-growing' || previous.status === 'accepted') && + msSincePrevious >= stabilityWindowMs; + + if (!stable) return { status: 'still-growing', bytes }; + + return { status: 'accepted', bytes }; +} + +/** + * Human-readable explanation of a non-acceptance, for the abort message. + * + * Reset aborts loudly rather than clearing on a doubtful file, so the architect + * needs to know which gate failed — "timed out" alone would not distinguish a + * builder that never responded from one that wrote a stub. + */ +export function describeReceiptFailure( + observation: ReceiptObservation, + statePath: string, + minBytes = DEFAULT_MIN_BYTES, +): string { + switch (observation.status) { + case 'missing': + return `${statePath} was never written. The builder may not have read the request (if it is wedged mid-turn, retry with --interrupt-first).`; + case 'wrong-nonce': + return `${statePath} exists (${observation.bytes} bytes) but does not carry this run's nonce — it is stale, left by an earlier reset. Refusing to clear on superseded state.`; + case 'too-small': + return `${statePath} carries the nonce but is only ${observation.bytes} bytes (minimum ${minBytes}). That is a stub, not a working-state save. Override with --min-bytes if this is genuinely all there was.`; + case 'still-growing': + return `${statePath} was still being written when the wait expired. Refusing to clear on a partial save.`; + case 'accepted': + return `${statePath} was accepted.`; + } +} + +/** + * Absolute path of the state file for a worktree. + * + * Uses `path.join` rather than string concatenation: this path is handed to the + * builder verbatim in the save request, so on Windows a hand-built + * `C:\repo\wt\` + `/` + name would instruct the builder to write to a path that + * is not the one the gate then stats. `path.join` also collapses redundant + * separators, so a trailing slash on the worktree is harmless. + */ +export function stateFilePath(worktreePath: string, fileName = STATE_FILE_NAME): string { + return join(worktreePath, fileName); +} diff --git a/packages/codev/src/agent-farm/commands/reset/reorient.ts b/packages/codev/src/agent-farm/commands/reset/reorient.ts new file mode 100644 index 000000000..6067aefc7 --- /dev/null +++ b/packages/codev/src/agent-farm/commands/reset/reorient.ts @@ -0,0 +1,420 @@ +/** + * Re-orientation assembly — invariant R3 (Spec 1273). + * + * R3: the re-orientation always carries role frame, protocol, mode, project + * identity, worktree, branch, the state-file pointer, and — for porch lanes — + * the porch re-entry instruction. **There is no code path that emits a partial + * frame**: a missing input throws a named error rather than being omitted. + * + * That strictness is the point. A re-orientation missing its frame does not + * crash anything; it produces a builder with a fresh window that does not know + * it is a builder, what protocol governs it, or what porch expects next. The + * drift is silent and only shows up later as off-protocol work. + * + * Two parts, with a fixed division: + * + * - `inline` — compact, sent as a message. Satisfies R3 on its own. Kept small + * because the message channel writes ≥4-line payloads line-by-line at 10ms + * intervals and multi-line writes risk paste detection (#584). + * - `longForm` — written to `.builder-reorient.md`. This IS the spawn prompt: + * `buildSpawnPrompt` is the same `buildPromptFromTemplate` the fresh-launch + * path calls, so the builder gets the same protocol/phase framing a fresh + * spawn delivers — through a file rather than a prompt argument. + * + * The role's *full text* is deliberately not inlined. Under the Claude harness it + * is injected via `--append-system-prompt`, a process flag that `/clear` does not + * touch, so it survives the reset intact. R3 is satisfied by the identity block + * regardless, so the guarantee does not rest on that harness detail. + */ + +import { REORIENT_FILE_NAME } from './constants.js'; +import type { ResolvedBuilderContext } from './context.js'; +import type { TemplateContext } from '../spawn-roles.js'; + +// ============================================================================ +// Ports +// ============================================================================ + +/** + * Renders the protocol's builder prompt. Injected so this module stays pure and + * so the orchestrator can wire it to the real `buildPromptFromTemplate`. + */ +/** + * Renders the porch re-entry guidance. Wired in phase 6 to `buildResumeNotice`, + * so reset and spawn share **one** copy of that text. + * + * Reuse matters beyond tidiness: `buildResumeNotice` carries the fallback + * instruction for when porch reports "not found" (run `porch init`). A restated + * version drops it, and the two surfaces then drift apart with no test noticing. + */ +export type ResumeNoticePort = (projectId: string) => string; + +/** + * Typed against the REAL `TemplateContext` rather than a hand-rolled copy. + * + * The local copy was how issue metadata went missing: the port's shape drifted + * from what the templates actually consume, and nothing type-checked the gap. + * Importing the canonical type means any field added to `TemplateContext` is + * visible here, and a field this module fails to populate is a deliberate + * omission rather than an oversight nobody can see. + */ +export type SpawnPromptPort = (protocol: string, context: TemplateContext) => string; + +/** + * Issue metadata for the spawn template. + * + * Every issue-driven protocol's builder prompt renders `{{issue.number}}`, + * `{{issue.title}}` and `{{issue.body}}` — and for BUGFIX and AIR the issue body + * *is* the spec. Omitting it would leave a reset builder on those lanes without + * the requirements it is implementing, which is the opposite of spawn-equivalent. + * + * Fetched by the orchestrator (I/O stays out of this module) and passed in. + */ +export interface IssuePayload { + number: number | string; + title: string; + body: string; +} + +// ============================================================================ +// Result +// ============================================================================ + +export interface ReorientationPayload { + /** Compact frame delivered as a message. Satisfies R3 by itself. */ + inline: string; + /** Full spawn-quality prompt, written to the worktree before the clear (R1). */ + longForm: string; + /** Worktree-relative path the inline frame points at. */ + longFormFileName: string; +} + +/** Thrown when a required frame element cannot be produced. Never swallowed. */ +export class ReorientationAssemblyError extends Error { + constructor(message: string) { + super(message); + this.name = 'ReorientationAssemblyError'; + } +} + +/** + * Elements every inline frame must contain, as literal markers. + * + * Assembly validates the rendered payload against this list before returning, so + * adding an element here without producing it fails the tests rather than + * silently shipping a frame that is missing it. + */ +export const REQUIRED_INLINE_MARKERS = [ + 'CONTEXT RESET', + 'You are a Builder', + // The spec requires the identity block to name WHICH role document governs + // the builder, not merely that one does. + '.builder-role.md', + 'Protocol:', + 'Mode:', + 'Worktree:', + 'Branch:', + 'State file:', + 'Full re-orientation:', +] as const; + +/** + * Markers required *when the context supplies the corresponding fact*. + * + * Project identity and porch re-entry are not universally applicable — a task or + * shell builder has neither — but on a porch lane they are as load-bearing as + * the protocol name, and omitting them would leave a reset builder unable to + * find its own project. So they are required conditionally rather than left + * optional: "missing input is an abort, not an omission" applies to whatever the + * lane actually has. + */ +export function conditionalInlineMarkers(context: ResolvedBuilderContext): string[] { + const markers: string[] = []; + // Project ID is required, not just the directory stem: porch commands take the + // id, and a reset builder should not have to parse it out of a slug. + if (context.porch) markers.push('Project ID:', 'Project:', 'porch next'); + if (context.issueNumber) markers.push('Issue:'); + return markers; +} + +// ============================================================================ +// Assembly +// ============================================================================ + +export interface AssembleOptions { + context: ResolvedBuilderContext; + /** Absolute path of the verified state file. */ + statePath: string; + /** Architect addendum from --note / --file. */ + addendum?: string; + buildSpawnPrompt: SpawnPromptPort; + /** Porch re-entry guidance; omitted only for non-porch lanes. */ + buildResumeNotice?: ResumeNoticePort; + /** + * Issue metadata, when the orchestrator could fetch it. Absence on an + * issue-backed lane is surfaced in the long form with a recovery instruction + * rather than silently dropped — see `buildLongForm`. + */ + issue?: IssuePayload; +} + +export function assembleReorientation(options: AssembleOptions): ReorientationPayload { + const { context, statePath, addendum, buildSpawnPrompt } = options; + + requireField(context.builderId, 'builderId'); + requireField(context.worktree, 'worktree'); + requireField(context.branch, 'branch'); + requireField(context.protocol, 'protocol'); + requireField(context.mode, 'mode'); + requireField(statePath, 'statePath'); + + // Porch identity is checked field by field, not just for the presence of the + // `porch` object. The marker validation below matches on LABELS, so an empty + // projectId still renders `Project ID:` and satisfies it — a frame that looks + // complete and tells the builder nothing. Presence of a label is not presence + // of a value. + if (context.porch) { + requireField(context.porch.projectId, 'porch.projectId'); + requireField(context.porch.projectName, 'porch.projectName'); + requireField(context.porch.phase, 'porch.phase'); + } + + const longForm = buildLongForm(options); + const inline = buildInline(options); + + // R3 is enforced here, not asserted in a comment: a frame that lost an element + // during a refactor fails at assembly rather than reaching a live builder. + const expected = [...REQUIRED_INLINE_MARKERS, ...conditionalInlineMarkers(context)]; + const missing = expected.filter(marker => !inline.includes(marker)); + if (missing.length > 0) { + throw new ReorientationAssemblyError( + `Assembled re-orientation is missing required element(s): ${missing.join(', ')}. ` + + `Refusing to clear a builder's context without a complete frame (R3).`, + ); + } + + return { inline, longForm, longFormFileName: REORIENT_FILE_NAME }; +} + +function requireField(value: unknown, name: string): void { + if (value === undefined || value === null || value === '') { + throw new ReorientationAssemblyError( + `Cannot assemble a re-orientation without '${name}'. ` + + `Refusing to emit a partial frame — a builder re-oriented without it would drift silently (R3).`, + ); + } +} + +// ============================================================================ +// Inline frame +// ============================================================================ + +function buildInline(options: AssembleOptions): string { + const { context: c, statePath, addendum } = options; + + const lines: string[] = [ + '## CONTEXT RESET — re-orientation', + '', + 'Your conversation history was cleared. Everything you knew that was not written', + 'down is gone. Do not try to recall it; read the files below instead.', + '', + // Name the role document rather than gesturing at it: a builder with no + // conversation history cannot resolve "your role document" to a file, and + // the spec requires the identity block to say WHICH document governs it. + // `.builder-role.md` is the copy the harness actually injected at spawn. + 'You are a Builder. Your role document is `.builder-role.md` at the worktree root', + '(the builder role, injected into your system prompt at spawn and still in effect).', + '', + `- Protocol: ${c.protocol.toUpperCase()}`, + `- Mode: ${c.mode.toUpperCase()}`, + ]; + + if (c.issueNumber) lines.push(`- Issue: #${c.issueNumber}`); + if (c.porch) { + // Project ID is stated explicitly, not left implicit in the directory stem: + // `porch status`/`porch next` take the id, and a reset builder should not + // have to parse it back out of a slug to address its own project. + lines.push(`- Project ID: ${c.porch.projectId}`); + lines.push(`- Project: ${c.porch.projectName} (phase: ${c.porch.phase}${c.porch.currentPlanPhase ? `, plan phase: ${c.porch.currentPlanPhase}` : ''})`); + } + if (c.specPath) lines.push(`- Spec: ${c.specPath}`); + if (c.planPath) lines.push(`- Plan: ${c.planPath}`); + + lines.push( + `- Worktree: ${c.worktree}`, + `- Branch: ${c.branch}`, + '', + '### Do this now, in order', + '', + `1. State file: read \`${statePath}\` **in full** before acting. It is the working`, + ' state your previous session wrote for exactly this moment — receipts, open', + ' questions, standing orders. Untracked: do not stage or commit it.', + `2. Full re-orientation: read \`${REORIENT_FILE_NAME}\` at the worktree root for the`, + ' complete protocol framing.', + ); + + if (c.porch) { + // Short pointer inline; the full guidance — including the `porch init` + // fallback — is carried verbatim in the long form from buildResumeNotice, + // so there is exactly one copy of that text in the codebase. + lines.push(`3. Run \`porch next\` to confirm where the protocol actually stands (full re-entry`, ` guidance is in ${REORIENT_FILE_NAME}), and continue.`); + } else { + lines.push('3. Continue from the next action named in the state file.'); + } + + if (addendum && addendum.trim() !== '') { + lines.push( + '', + '### From the architect (this post-dates your save)', + '', + addendum.trim(), + ); + } + + return lines.join('\n'); +} + +// ============================================================================ +// Long form +// ============================================================================ + +/** + * Reconstruct `input_description` (and `task_text`) the way the matching spawn + * entry point would. + * + * `{{input_description}}` is the FIRST line of every protocol's builder prompt + * (`bugfix/air/aspir/spir/builder-prompt.md:3`), so getting it wrong mis-frames + * the whole document — the reset builder is told it is doing something other + * than what it is doing. Spawn produces four distinct values, one per entry + * point, and this mirrors all four rather than approximating with two: + * + * - spec-driven → `spawn.ts:455` (also the lane that attaches an issue) + * - ad-hoc task → `spawn.ts:543` (carries `task_text` as well) + * - issue-driven → `spawn.ts:837` (BUGFIX/AIR; the issue body IS the spec) + * - protocol-only → `spawn.ts:607` + * + * **Order is load-bearing, and not the obvious one.** A `--task` builder gets a + * porch project keyed on its own builder id, so `issueNumber` is populated for + * it too (`context.ts` falls back to `porch.projectId`). Testing `issueNumber` + * before `taskText` would therefore route every task builder down the + * issue-driven branch and announce a GitHub issue that does not exist. Spec wins + * over both because the spec-driven spawn path sets its own description even + * when an issue is attached. + */ +function buildInputDescription( + c: ResolvedBuilderContext, +): Pick<TemplateContext, 'input_description' | 'task_text'> { + if (c.specPath) return { input_description: `the feature specified in ${c.specPath}` }; + if (c.taskText) return { input_description: 'an ad-hoc task', task_text: c.taskText }; + if (c.issueNumber) return { input_description: `work for GitHub Issue #${c.issueNumber}` }; + return { input_description: `running the ${c.protocol.toUpperCase()} protocol` }; +} + +function buildLongForm(options: AssembleOptions): string { + const { context: c, statePath, addendum, buildSpawnPrompt, buildResumeNotice, issue } = options; + + let spawnPrompt: string; + try { + spawnPrompt = buildSpawnPrompt(c.protocol, { + protocol_name: c.protocol.toUpperCase(), + mode: c.mode, + mode_soft: c.mode === 'soft', + mode_strict: c.mode === 'strict', + project_id: c.porch?.projectId, + ...buildInputDescription(c), + ...(c.specPath && c.specName ? { spec: { path: c.specPath, name: c.specName } } : {}), + ...(c.planPath && c.specName ? { plan: { path: c.planPath, name: c.specName } } : {}), + // Issue-driven protocols render {{issue.*}} in their builder prompt, and + // on BUGFIX/AIR the body IS the spec. Forwarding it is what makes the long + // form genuinely spawn-equivalent rather than spawn-shaped. + ...(issue ? { issue: { number: issue.number, title: issue.title, body: issue.body } } : {}), + }); + } catch (err) { + // Abort rather than degrade: a long form without the protocol framing is the + // partial frame R3 forbids, and it would be delivered to a builder with no + // context left to notice the gap. + throw new ReorientationAssemblyError( + `Could not render the ${c.protocol} builder prompt for the long-form re-orientation: ` + + `${err instanceof Error ? err.message : String(err)}. Refusing to clear without it (R1/R3).`, + ); + } + + const header = [ + '<!-- Written by `afx reset` (Spec 1273). Untracked; regenerated on every reset. -->', + '', + '# Re-orientation after context reset', + '', + `Your conversation history was cleared deliberately, to give you a fresh window`, + `without losing your working state. This file restores the protocol framing a`, + `fresh spawn would have given you; \`${statePath}\` holds what your previous`, + `session actually knew.`, + '', + '## Read order', + '', + `1. \`${statePath}\` — your working state (receipts, open questions, standing orders).`, + '2. The rest of this file — protocol framing, identical to a fresh spawn prompt.', + c.porch ? '3. `porch next` — the authoritative protocol state.' : '3. The next action named in your state file.', + '', + '## Current position', + '', + `- Builder: ${c.builderId}`, + `- Protocol: ${c.protocol.toUpperCase()} (${c.mode})`, + `- Worktree: ${c.worktree}`, + `- Branch: ${c.branch}`, + ]; + + if (c.porch) { + header.push( + `- Porch project ID: ${c.porch.projectId}`, + `- Porch project: ${c.porch.projectName}`, + `- Phase: ${c.porch.phase}${c.porch.currentPlanPhase ? ` (plan phase: ${c.porch.currentPlanPhase})` : ''}`, + ); + } + if (c.issueNumber) header.push(`- Issue: #${c.issueNumber}`); + + header.push( + '', + `Protocol and mode were resolved from ${c.protocolSource} and ${c.modeSource} respectively.`, + ); + + // An issue-backed lane whose issue could not be fetched keeps a VISIBLE gap + // with a recovery instruction, rather than a silently shorter prompt. On + // BUGFIX/AIR the issue body is the spec, so a reset builder must be told the + // requirements are missing instead of inferring them from what remains. + if (c.issueNumber && !issue) { + header.push( + '', + `> **Issue #${c.issueNumber} could not be fetched when this file was written**, so the`, + `> protocol framing below does not include its title or body. On issue-driven`, + `> protocols that body carries the requirements. Read it before continuing:`, + `> \`gh issue view ${c.issueNumber}\`.`, + ); + } + + if (addendum && addendum.trim() !== '') { + header.push( + '', + '## From the architect (post-dates your save)', + '', + addendum.trim(), + ); + } + + // Porch re-entry, verbatim from the single source shared with spawn. Reset must + // not restate it: buildResumeNotice carries the `porch init` fallback for when + // porch reports "not found", and a restated copy silently drops it. + if (c.porch) { + if (!buildResumeNotice) { + throw new ReorientationAssemblyError( + `Cannot assemble a re-orientation for porch project '${c.porch.projectName}' without the ` + + `porch re-entry notice. Refusing to emit a frame that leaves a porch-driven builder ` + + `without its re-entry instruction (R3).`, + ); + } + header.push('', '---', '', buildResumeNotice(c.porch.projectId).trim()); + } + + header.push('', '---', '', '## Protocol framing (as delivered at spawn)', ''); + + return `${header.join('\n')}\n${spawnPrompt}\n`; +} diff --git a/packages/codev/src/agent-farm/servers/message-write.ts b/packages/codev/src/agent-farm/servers/message-write.ts index 365e0accc..8efaeddca 100644 --- a/packages/codev/src/agent-farm/servers/message-write.ts +++ b/packages/codev/src/agent-farm/servers/message-write.ts @@ -18,6 +18,38 @@ const INTER_LINE_DELAY_MS = 10; const PACED_ENTER_DELAY_MS = 80; const SIMPLE_ENTER_DELAY_MS = 50; +/** ESC keystroke — ends the agent's current turn (Spec 1273). */ +export const ESC = '\x1b'; + +/** + * Delay between the ESC and the Enter that follows it. Matches the short-message + * Enter delay: ESC has to be processed by the TUI before Enter is meaningful. + */ +export const ESCAPE_ENTER_DELAY_MS = SIMPLE_ENTER_DELAY_MS; + +/** + * Write a bare ESC keystroke to a PTY session (Spec 1273). + * + * This is the verified mid-turn recovery for a wedged agent: ESC interrupts the + * running tool and ends the turn, after which queued messages process. It is the + * command form of `afx send <builder> --raw "$(printf '\x1b')"`. + * + * The trailing Enter is sent by default and is load-bearing, not incidental — + * it is what lets already-queued input through once ESC has ended the turn. Pass + * `noEnter` to write ESC alone. + * + * Deliberately not routed through `writeMessageToSession`: ESC is a control byte, + * not text, so line-pacing and paste-detection logic do not apply to it. + * + * @returns ms timestamp (from call time) when all writes complete + */ +export function writeEscapeToSession(session: WritableSession, noEnter: boolean): number { + session.write(ESC); + if (noEnter) return 0; + setTimeout(() => session.write('\r'), ESCAPE_ENTER_DELAY_MS); + return ESCAPE_ENTER_DELAY_MS; +} + /** * Write a message to a PTY session, pacing multi-line output to prevent * the terminal from treating it as a paste (Bugfix #584). diff --git a/packages/codev/src/agent-farm/servers/tower-messages.ts b/packages/codev/src/agent-farm/servers/tower-messages.ts index f708ef7d1..ebca6daf2 100644 --- a/packages/codev/src/agent-farm/servers/tower-messages.ts +++ b/packages/codev/src/agent-farm/servers/tower-messages.ts @@ -65,7 +65,7 @@ export interface MessageFrame { from: { project: string; agent: string }; to: { project: string; agent: string }; content: string; - metadata: { raw?: boolean; source?: string }; + metadata: { raw?: boolean; source?: string; escape?: boolean }; } // ============================================================================ diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index f102bce7f..b11acd846 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -51,7 +51,7 @@ import { formatArchitectMessage, formatBuilderMessage } from '../utils/message-f import { SendBuffer } from './send-buffer.js'; import type { BufferedMessage } from './send-buffer.js'; import type { PtySession } from '../../terminal/pty-session.js'; -import { writeMessageToSession } from './message-write.js'; +import { writeMessageToSession, writeEscapeToSession } from './message-write.js'; import { getKnownWorkspacePaths, getInstances, @@ -1455,6 +1455,7 @@ async function handleSend( const raw = options.raw === true; const noEnter = options.noEnter === true; const interrupt = options.interrupt === true; + const escape = options.escape === true; // Resolve the target address to a terminal ID. // Spec 755: pass `from` so architect resolution is sender-affinity-aware @@ -1497,6 +1498,33 @@ async function handleSend( return; } + // Spec 1273: `escape` delivers a bare ESC keystroke and returns. It is handled + // before formatting and before the send buffer on purpose — an interrupt that + // can be deferred because someone recently typed in this terminal is not an + // interrupt. ESC ends the running turn so already-queued messages process; the + // trailing Enter is what lets them through, which is why it is the default + // (matching the verified recovery `afx send <b> --raw "$(printf '\x1b')"`). + if (escape) { + writeEscapeToSession(session, noEnter); + broadcastMessage({ + type: 'message', + from: { project: path.basename(fromWorkspace ?? workspace ?? 'unknown'), agent: from ?? 'unknown' }, + to: { project: path.basename(result.workspacePath), agent: result.agent }, + content: '<ESC>', + metadata: { raw: true, source: 'api', escape: true }, + timestamp: new Date().toISOString(), + }); + ctx.log('INFO', `Interrupt (ESC) sent: ${from ?? 'unknown'} → ${result.agent} (terminal ${result.terminalId.slice(0, 8)}...)`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + terminalId: result.terminalId, + resolvedTo: result.agent, + deferred: false, + })); + return; + } + // Format the message based on sender/target const isArchitectTarget = result.agent === 'architect'; let formattedMessage: string; diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index 598a84ca6..a32ac1812 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -166,6 +166,36 @@ export interface SendOptions { noEnter?: boolean; // Don't send Enter after message } +/** + * Options for `afx interrupt` (Spec 1273). + * + * Sends a bare ESC keystroke into a builder's PTY — the only recovery that + * reaches a builder mid-turn, ending the turn so queued messages process. + */ +export interface InterruptOptions { + builder?: string; // Builder ID / short id (required) + noEnter?: boolean; // Write ESC alone, without the trailing Enter +} + +/** + * Options for `afx reset` — save-state → /clear → re-orient (Spec 1273). + * + * The timing knobs exist because the safe failure of every gate is "abort + * without clearing". A builder that legitimately needs longer than a default + * should be given longer, not have the gate weakened. + */ +export interface ResetOptions { + builder?: string; // Builder ID / short id (required) + note?: string; // Inline addendum appended to the re-orientation + file?: string; // Addendum read from the CALLER's filesystem (48KB cap) + dryRun?: boolean; // Print the payloads; write nothing to the builder + interruptFirst?: boolean; // ESC before the save request, for a wedged builder + mode?: 'strict' | 'soft'; // Override when the builder prompt file is gone + timeout?: number; // Seconds to wait for the save-state receipt + minBytes?: number; // Minimum state-file size to count as substantive + quietWindow?: number; // ms of terminal silence that counts as turn-ended +} + /** * User-facing config.json structure */ diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index 319bb4bdb..fb7f438be 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -39,6 +39,17 @@ export interface HarnessProvider { env: Record<string, string>; }; + /** + * Whether this harness can clear its conversation context in-session, without + * restarting the process (Spec 1273 — `afx reset`). + * + * Optional, and absence means "no": a harness that has not declared support + * must not be reset, and defaulting to unsupported is the safe direction. Only + * Claude declares it today (`/clear`), which is why `afx reset` refuses other + * harnesses loudly rather than improvising a substitute mechanism. + */ + supportsContextReset?: boolean; + /** * Optional: files to write in the worktree before launching the agent. * Used by harnesses that rely on file-based configuration (e.g., OpenCode @@ -116,6 +127,10 @@ export interface CustomHarnessConfig { // ============================================================================= export const CLAUDE_HARNESS: HarnessProvider = { + // Spec 1273: `/clear` empties the conversation while leaving the process — and + // therefore the --append-system-prompt role below — intact. That is what makes + // an in-session reset possible here and nowhere else today. + supportsContextReset: true, buildRoleInjection: (content, _filePath) => ({ args: ['--append-system-prompt', content], env: {}, @@ -186,7 +201,12 @@ export const OPENCODE_HARNESS: HarnessProvider = { }]), }; -const BUILTIN_HARNESSES: Record<string, HarnessProvider> = { +/** + * Exported for Spec 1273: `afx reset` identifies a running builder's harness from + * its launch script and must check `supportsContextReset` before typing into the + * terminal. It needs the name→provider map, not just the workspace default. + */ +export const BUILTIN_HARNESSES: Record<string, HarnessProvider> = { claude: CLAUDE_HARNESS, codex: CODEX_HARNESS, gemini: GEMINI_HARNESS, diff --git a/packages/codev/src/terminal/pty-session.ts b/packages/codev/src/terminal/pty-session.ts index 9a091485c..d6d537fe2 100644 --- a/packages/codev/src/terminal/pty-session.ts +++ b/packages/codev/src/terminal/pty-session.ts @@ -37,6 +37,25 @@ export interface PtySessionInfo { createdAt: string; exitCode?: number; persistent?: boolean; + /** + * Whether input can actually reach the process RIGHT NOW (Spec 1273). + * + * Serialised alongside `status` because the two disagree in the case that + * matters: a session whose shellper connection died reports status 'running' + * until teardown while every write to it is dropped (#1198). `afx reset` + * preflights on this so it refuses a terminal it cannot write to BEFORE + * touching anything, rather than discovering it on the first send. + */ + writable?: boolean; + /** + * Epoch ms of the last PTY output (Spec 467's tracking, surfaced by Spec 1273). + * + * Serialised by `GET /api/terminals/:id`, which makes output quiescence + * *measurable* by a client: an agent mid-turn emits continuously (spinner + * frames, streamed tokens), so a stretch with no advance means the turn ended. + * `afx reset` uses this to avoid typing into a terminal that is still working. + */ + lastDataAt: number; } /** @@ -512,6 +531,8 @@ export class PtySession extends EventEmitter { createdAt: this.createdAt, exitCode: this.exitCode, persistent: this._shellperBacked, + lastDataAt: this._lastDataAt, + writable: this.writable, }; } diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts index df0a3a993..dcc1c6196 100644 --- a/packages/core/src/tower-client.ts +++ b/packages/core/src/tower-client.ts @@ -147,6 +147,23 @@ export interface TowerTerminal { status: 'running' | 'exited'; createdAt: string; wsPath: string; + /** + * Epoch ms of the last PTY output (Spec 1273). Lets a client measure output + * quiescence — an agent mid-turn emits continuously, so a stretch with no + * advance means the turn ended. Optional: terminals served by an older Tower + * omit it, and a consumer that needs it must say so rather than assume 0. + */ + lastDataAt?: number; + /** + * Whether input can actually reach the process right now (Spec 1273). + * + * Distinct from `status`, and they disagree in the case that matters: a + * session whose shellper connection died reports `status: 'running'` while + * every write to it is dropped (#1198). Optional for the same reason + * `lastDataAt` is — an older Tower omits it, and a consumer that needs it + * must handle absence rather than assume `false`. + */ + writable?: boolean; } // ── Client Options ───────────────────────────────────────────── @@ -518,6 +535,25 @@ export class TowerClient { return result.ok ? result.data! : null; } + /** + * Recent PTY output for a terminal (Spec 1273). + * + * The `/output` route has existed since the terminal manager was written; it + * simply had no client binding. Reset uses it for the best-effort `/clear` + * confirmation — without it that check can never succeed outside tests, and + * every real run would report the clear as unconfirmed while looking like it + * had tried. + */ + async getTerminalOutput( + terminalId: string, + lines = 100, + ): Promise<{ lines: string[]; total: number; hasMore: boolean } | null> { + const result = await this.request<{ lines: string[]; total: number; hasMore: boolean }>( + `/api/terminals/${terminalId}/output?lines=${lines}`, + ); + return result.ok ? result.data! : null; + } + async writeTerminal(terminalId: string, data: string): Promise<boolean> { const result = await this.request(`/api/terminals/${terminalId}/write`, { method: 'POST', @@ -626,6 +662,13 @@ export class TowerClient { raw?: boolean; noEnter?: boolean; interrupt?: boolean; + /** + * Spec 1273: deliver the message as a bare ESC keystroke (`\x1b`) written + * straight to the PTY — no formatting, no send-buffer deferral. This is the + * verified mid-turn recovery: ESC ends the running turn so queued messages + * can process. Distinct from `interrupt`, which sends Ctrl+C (`\x03`). + */ + escape?: boolean; }, ): Promise<{ ok: boolean; resolvedTo?: string; error?: string }> { const result = await this.request<{ ok: boolean; resolvedTo: string }>( @@ -642,6 +685,7 @@ export class TowerClient { raw: options?.raw, noEnter: options?.noEnter, interrupt: options?.interrupt, + escape: options?.escape, }, }), },