fix(phased-execution): resume failed session on retry, surface errors, thinking/compaction indicators

- retries now resume the failed executor's session (pre/post session
  tracking replaces the broken mtime-vs-ref check)
- report recovery from the session file when the JSON stream loses its
  tail (child signaled mid-flush)
- progress.mjs: thinking indicators (◐ thinking… / ◑ thought for Ns),
  compaction (⧉ …) and provider auto-retry lines; trims trailing
  whitespace so no blank lines after LLM text; exits 1 on truncated
  stream, model error, or missing final text (pi --mode json always
  exits 0 even on errors)
- explicit ✗ ERROR lines + exit codes: 0 ok, 1 phase failed, 130/143
  interrupted (INT/TERM traps; post-pipeline check covers the case
  where bash suppresses the INT trap after a job dies from SIGINT)
- PIPESTATUS captured in a single statement (any following command
  resets it)
- skills dir: .gitignore README.md so pi's skill scanner (which honors
  .gitignore) stops warning 'description is required'
This commit is contained in:
2026-08-21 11:00:21 -04:00
parent f96ee9382e
commit 3bacbff874
7 changed files with 279 additions and 159 deletions
+5
View File
@@ -0,0 +1,5 @@
# pi treats any root-level .md file in a skills directory as a skill and
# warns "description is required" for non-skill markdown like this README.
# pi's skill scanner honors .gitignore files, so ignoring README.md here
# keeps it out of skill discovery (and out of git).
README.md
-109
View File
@@ -1,109 +0,0 @@
# Skills
A collection of reusable agent skills that extend an LLM coding assistant's capabilities. Install any skill by cloning or symlinking it into your agent's skills directory — for example, for pi:
```bash
git clone git@git.reeseapps.com:Vibes/skills.git ~/.pi/agent/skills/
```
## Current Skills
| Skill | Description |
|-------|-------------|
| [phased-execution](./phased-execution/) | Runs phased build pipelines in isolated subprocesses |
| [find-skills](./find-skills/) | Discovers and installs new skills |
| [gog](./gog/) | Google Workspace CLI (Gmail, Calendar, Drive, Contacts, Sheets, Docs) |
---
## phased-execution
Runs phased build pipelines in isolated subprocesses. Ported from [opencode](https://github.com/opencode-ai/opencode)'s `next-phase` / `auto-phase` commands.
### Overview
This skill orchestrates a project's development as a sequence of phases, each executed by the LLM in its own fresh process. Phases are managed via files — not chat context — so even very long-running pipelines don't bloat your session history.
Each phase:
1. Reads the master plan (`.agent/PLAN.md`) and any already-completed phases
2. Implements all tasks from its phase file
3. Runs tests, linting, and coverage checks via `.agent/validate.sh`
4. Retries up to `MAX_FIX_ATTEMPTS` times on failure (resuming the same session)
5. Moves to `complete/` only when everything passes
### Directory Structure
```
<project>/
├── .agent/
│ ├── PLAN.md # Master plan; LOCKED DECISIONS are binding
│ ├── validate.sh # Quality gate (created from skill template on first run)
│ ├── phases/
│ │ ├── todo/ # Pending phases: 01_name.md, 02_name.md, …
│ │ └── complete/ # Finished phases
│ ├── reports/ # Per-phase executor reports, stderr, validation logs
│ └── phase-sessions/ # Resumable child sessions
```
### Usage
From within a project that has `.agent/phases/todo/`:
**Run the next pending phase:**
```bash
cd /path/to/project
bash ~/.pi/agent/skills/phased-execution/scripts/run-phase.sh
```
**Run a specific phase:**
```bash
bash ~/.pi/agent/skills/phased-execution/scripts/run-phase.sh 03_api.md
```
**Run the entire pipeline** (all pending phases, in order, stopping at first failure):
```bash
bash ~/.pi/agent/skills/phased-execution/scripts/auto-phase.sh
```
Re-running `auto-phase.sh` after a failure continues where it stopped.
### Configuration
Environment variables passed to the scripts control behavior:
| Variable | Default | Meaning |
|----------|---------|---------|
| `MAX_FIX_ATTEMPTS` | `3` | Fixer retries per phase |
| `PHASE_MODEL` | session default | Model for child executors (e.g. `anthropic/claude-sonnet-4-5`) |
| `PHASE_THINKING` | session default | Thinking level for child executors |
| `PHASE_COMMIT` | `0` | `1` = auto-git-commit after each passing phase |
| `PI_TRUST` | `0` | `1` = pass `--approve` to children (load project `.pi/` settings) |
| `FRESH_FIX` | `0` | `1` = fixer retries start fresh instead of resuming the failed session |
| `QUIET` | `0` | `1` = suppress live progress display (reports still written) |
Example:
```bash
PHASE_MODEL=anthropic/claude-sonnet-4-5 PHASE_COMMIT=1 bash ~/.pi/agent/skills/phased-execution/scripts/auto-phase.sh
```
### How It Works
1. `run-phase.sh` / `auto-phase.sh` finds the project root (walks up from `PWD` for `.agent/phases/todo/`)
2. For each phase, it spawns a **separate process** with `--mode json` to capture structured output
3. `progress.mjs` streams live progress (tool calls, assistant text) to the terminal and writes the final report to `.agent/reports/`
4. After the child exits, `.agent/validate.sh` is run as the quality gate
5. On success: phase moves to `complete/`, report is printed
6. On failure: up to `MAX_FIX_ATTEMPTS` fixer retries (resuming the same session), then the phase stays in `todo/`
### After a Run
- **Success**: The executor report is printed at the end of the script output. Phases in `.agent/phases/complete/` are already done.
- **Failure**: Point to `.agent/reports/<phase>.a*.{md,err,validate}` for logs. Resume with:
```bash
pi --session-dir .agent/phase-sessions -c
```
Or re-run the script to retry automatically.
### Creating Phases
Phase files are typically created by prompt templates like `/to-phase`, `/audit-create`, `/new-project`, and `/new-python-*`. Each phase file in `todo/` should list concrete tasks the executor must complete, including testing & quality criteria.
+27 -8
View File
@@ -14,11 +14,20 @@ Phase state lives in files, not chat:
- `.agent/validate.sh` — the pass/fail gate for every phase - `.agent/validate.sh` — the pass/fail gate for every phase
The scripts run each phase in a **separate pi process** (fresh context) with The scripts run each phase in a **separate pi process** (fresh context) with
bounded fixer retries (the failed executor's session is resumed, so retries bounded fixer retries. A phase only moves to `complete/` after the child exits
keep its work). A phase only moves to `complete/` after the child exits 0 0, the child's stream ends with a clean final report, **and**
**and** `.agent/validate.sh` passes. This chat only dispatches and relays `.agent/validate.sh` passes. This chat only dispatches and relays results —
results — do not implement phase code yourself; that is what the subprocess do not implement phase code yourself; that is what the subprocess is for.
is for.
## Live display
While a phase runs, `scripts/progress.mjs` relays the child's JSON stream to
the terminal: tool calls, assistant text, `◐ thinking…` / `◑ thought for Ns`
indicators, yellow `⧉ compacting context` lines (these can take minutes —
not a hang), and provider auto-retry notices. If the child dies mid-flush and
the stream loses the final message, the report is recovered from the
child's session file (`.agent/phase-sessions/`), so a completed phase is
never lost to a truncated stream.
## Commands ## Commands
@@ -42,11 +51,17 @@ Re-running `auto-phase.sh` after a failure continues where it stopped.
## After a run ## After a run
Exit codes: `0` = success (or nothing to run), `1` = phase failed after all
attempts / no pending phases error, `130`/`143` = interrupted (Ctrl+C / SIGTERM).
Failures always print a `✗ ERROR:` line with the last error output — if the
script's output looks like it ended abruptly, re-run it; the failed executor's
session is resumed automatically (retries continue the child's own session,
keeping its work).
Relay to the user: the phase name, its executor report (printed at the end of Relay to the user: the phase name, its executor report (printed at the end of
the script output), and the validation outcome. On failure, point the user at the script output), and the validation outcome. On failure, point the user at
`.agent/reports/<phase>.a*.{md,err,validate}` and offer to resume the failed `.agent/reports/<phase>.a*.{md,err,validate}` — the script also prints a ready
executor's session: `pi --session-dir .agent/phase-sessions -c` (or suggest to run `pi --session … -c “…”` command to continue the failed session manually.
running the script again to retry automatically).
## Configuration (environment variables) ## Configuration (environment variables)
@@ -69,3 +84,7 @@ running the script again to retry automatically).
and `/new-python-*` prompt templates. and `/new-python-*` prompt templates.
- Child executor sessions are kept in `.agent/phase-sessions/`; add it to - Child executor sessions are kept in `.agent/phase-sessions/`; add it to
`.gitignore` if the project is versioned. `.gitignore` if the project is versioned.
- If you keep non-skill markdown (e.g. a `README.md`) in a skills directory
(like `~/.pi/agent/skills/`), pi warns “description is required” for it.
Add a `.gitignore` in that directory listing the file — pi's skill scanner
honors `.gitignore`, so the file is skipped.
+10 -1
View File
@@ -14,13 +14,22 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib.sh" source "$SCRIPT_DIR/lib.sh"
# Make interruptions visible: state stays in .agent/phases/todo, and the
# failed executor's session is still resumable on the next run.
trap 'echo; echo "✗ ERROR: interrupted (SIGINT) — ${phase:-the pipeline} is left in $PHASE_TODO/; re-run to continue where it stopped" >&2; exit 130' INT
trap 'echo; echo "✗ ERROR: interrupted (SIGTERM) — ${phase:-the pipeline} is left in $PHASE_TODO/; re-run to continue where it stopped" >&2; exit 143' TERM
cd "$(find_root)" || die "no .agent/phases/todo found in this or parent directories (run /to-phase or /audit-create first)" cd "$(find_root)" || die "no .agent/phases/todo found in this or parent directories (run /to-phase or /audit-create first)"
build_pi_args build_pi_args
delivered=() delivered=()
while phase="$(next_phase)"; do while phase="$(next_phase)"; do
[[ -n "$phase" ]] || break [[ -n "$phase" ]] || break
execute_phase "$phase" || exit 1 if ! execute_phase "$phase"; then
echo "✗ ERROR: pipeline stopped — $phase FAILED after $MAX_FIX_ATTEMPTS attempts" >&2
echo " fix the issues above, then re-run this script to continue where it stopped" >&2
exit 1
fi
delivered+=("$phase") delivered+=("$phase")
done done
+111 -26
View File
@@ -59,6 +59,35 @@ latest_child_session() {
ls -t "$PHASE_SESSIONS"/*.jsonl 2>/dev/null | head -n1 || true ls -t "$PHASE_SESSIONS"/*.jsonl 2>/dev/null | head -n1 || true
} }
# Write the last assistant text message of a session file to a report file.
# Recovery path: when the child's JSON stream loses its tail (e.g. the child
# is signaled mid-flush), the session file still holds the final report.
# Returns 1 when there is no recoverable assistant text.
recover_report() {
local report="$1" session="$2"
[[ -f "$session" ]] || return 1
node -e '
const [report, file] = process.argv.slice(1);
const { readFileSync, writeFileSync } = require("node:fs");
let last = "";
for (const line of readFileSync(file, "utf8").split("\n")) {
const t = line.trim();
if (!t) continue;
let e;
try { e = JSON.parse(t); } catch { continue; }
const m = e && e.type === "message" ? e.message : null;
if (!m || m.role !== "assistant") continue;
let text = "";
if (typeof m.content === "string") text = m.content;
else if (Array.isArray(m.content))
text = m.content.filter((b) => b && b.type === "text").map((b) => b.text).join("");
if (text.trim()) last = text.trim();
}
if (!last) process.exit(1);
writeFileSync(report, last + "\n");
' "$report" "$session"
}
# --- prompts ------------------------------------------------------------------ # --- prompts ------------------------------------------------------------------
first_prompt() { first_prompt() {
local phase="$1" local phase="$1"
@@ -82,41 +111,63 @@ fix_prompt() {
} }
# --- child executor ----------------------------------------------------------- # --- child executor -----------------------------------------------------------
# run_child <phase> <attempt> <prompt> # run_child <phase> <attempt> <prompt> [resume-session]
# Attempt 1: fresh session in .agent/phase-sessions/. # Attempt 1: fresh session in .agent/phase-sessions/.
# Attempt N>1: resume attempt N-1's session (unless FRESH_FIX=1 or no session # Attempt N>1: resumes the given session file — the failed executor's own
# was created — in that case a fresh ephemeral session with the failure context). # session, tracked by execute_phase (so retries keep its work). When no
# Progress (tool calls + assistant text) streams to the terminal live via # session was captured (or FRESH_FIX=1) a fresh ephemeral session runs with
# scripts/progress.mjs, which also writes the final assistant message to: # the failure context instead.
# .agent/reports/<base>.a<attempt>.md # Progress (tool calls, assistant text, thinking, compaction) streams to the
# terminal live via scripts/progress.mjs, which also writes the final
# assistant message to: .agent/reports/<base>.a<attempt>.md
# Child stderr → .agent/reports/<base>.a<attempt>.err # Child stderr → .agent/reports/<base>.a<attempt>.err
# QUIET=1 suppresses the progress display (report file is still written). # QUIET=1 suppresses the progress display (report file is still written).
# Sets CHILD_RC. # Sets CHILD_RC (pi's exit code) and PROGRESS_RC (progress.mjs's exit code;
# non-zero means the stream ended without a clean final report).
# Run `pi … | progress.mjs`, capturing both exit codes. Must be the direct
# caller of the pipeline, and PIPESTATUS must be copied in ONE statement —
# any following command (even a plain assignment) resets it.
_run_pi_pipeline() {
local errf="$1"; shift
"$@" 2>"$errf" | "${PROGRESS[@]}"
local rcs=( "${PIPESTATUS[@]}" )
CHILD_RC=${rcs[0]}
PROGRESS_RC=${rcs[1]}
# Ctrl+C sends INT to the whole foreground group: pi and progress.mjs die
# with 130, and bash then SUPPRESSES the script's INT trap (it assumes the
# job already handled the signal). Detect the kill here and exit as if the
# trap had fired, so the failure is always visible.
if (( CHILD_RC == 130 || PROGRESS_RC == 130 )); then
echo
echo "✗ ERROR: interrupted (SIGINT) — phase is left in $PHASE_TODO/; re-run to continue" >&2
exit 130
fi
if (( CHILD_RC == 143 || PROGRESS_RC == 143 )); then
echo
echo "✗ ERROR: interrupted (SIGTERM) — phase is left in $PHASE_TODO/; re-run to continue" >&2
exit 143
fi
}
run_child() { run_child() {
local phase="$1" attempt="$2" prompt="$3" local phase="$1" attempt="$2" prompt="$3" resume_session="${4:-}"
local base="${phase%.md}" local base="${phase%.md}"
local out="$PHASE_REPORTS/$base.a$attempt.md" local out="$PHASE_REPORTS/$base.a$attempt.md"
local errf="$PHASE_REPORTS/$base.a$attempt.err" local errf="$PHASE_REPORTS/$base.a$attempt.err"
local ref="$PHASE_REPORTS/.ref"
local session
local PROGRESS=(node "$SKILL_DIR/scripts/progress.mjs" "$out") local PROGRESS=(node "$SKILL_DIR/scripts/progress.mjs" "$out")
[[ "${QUIET:-0}" == "1" ]] && PROGRESS+=(--quiet) [[ "${QUIET:-0}" == "1" ]] && PROGRESS+=(--quiet)
if (( attempt == 1 )); then if (( attempt == 1 )); then
touch "$ref" _run_pi_pipeline "$errf" pi "${PI_ARGS[@]}" --session-dir "$PHASE_SESSIONS" --name "$base" --mode json "$prompt"
pi "${PI_ARGS[@]}" --session-dir "$PHASE_SESSIONS" --name "$base" --mode json "$prompt" 2>"$errf" | "${PROGRESS[@]}" elif [[ "${FRESH_FIX:-0}" == "1" || -z "$resume_session" || ! -f "$resume_session" ]]; then
echo " (no resumable session — starting fresh)"
_run_pi_pipeline "$errf" pi "${PI_ARGS[@]}" --no-session --mode json "$prompt"
else else
touch "$ref" echo " (resuming failed executor session: ${resume_session##*/})"
session="$(latest_child_session)" _run_pi_pipeline "$errf" pi "${PI_ARGS[@]}" --session "$resume_session" --mode json "$prompt"
if [[ "${FRESH_FIX:-0}" != "1" && -n "$session" && "$session" -nt "$ref" ]]; then
echo " (resuming failed executor session: ${session##*/})"
pi "${PI_ARGS[@]}" --session "$session" --mode json "$prompt" 2>"$errf" | "${PROGRESS[@]}"
else
echo " (no resumable session — starting fresh)"
pi "${PI_ARGS[@]}" --no-session --mode json "$prompt" 2>"$errf" | "${PROGRESS[@]}"
fi
fi fi
CHILD_RC=${PIPESTATUS[0]}
} }
# --- validation gate ---------------------------------------------------------- # --- validation gate ----------------------------------------------------------
@@ -142,22 +193,47 @@ execute_phase() {
local phase="$1" local phase="$1"
local base="${phase%.md}" local base="${phase%.md}"
local attempt=1 errors="" local attempt=1 errors=""
local last_session="" pre post
command -v node >/dev/null 2>&1 || die "node not found on PATH (needed to render phase progress)" command -v node >/dev/null 2>&1 || die "node not found on PATH (needed to render phase progress)"
mkdir -p "$PHASE_DONE" "$PHASE_REPORTS" "$PHASE_SESSIONS" mkdir -p "$PHASE_DONE" "$PHASE_REPORTS" "$PHASE_SESSIONS"
ensure_validate ensure_validate
while (( attempt <= MAX_FIX_ATTEMPTS )); do while (( attempt <= MAX_FIX_ATTEMPTS )); do
echo "━━ $phase — attempt $attempt/$MAX_FIX_ATTEMPTS ━━" echo "━━ $phase — attempt $attempt/$MAX_FIX_ATTEMPTS ━━"
pre="$(latest_child_session)"
if (( attempt == 1 )); then if (( attempt == 1 )); then
run_child "$phase" 1 "$(first_prompt "$phase")" run_child "$phase" 1 "$(first_prompt "$phase")"
else else
run_child "$phase" "$attempt" "$(fix_prompt "$errors")" run_child "$phase" "$attempt" "$(fix_prompt "$errors")" "$last_session"
fi fi
# Track which session file this attempt used, so the next attempt resumes
# exactly this phase's failed session (not just "latest in the directory").
post="$(latest_child_session)"
if [[ -n "$post" && "$post" != "$pre" ]]; then
last_session="$post"
fi
# The child can be signaled mid-flush: the session file then holds a final
# report the stream lost. Recover it so a completed phase is not retried.
if (( CHILD_RC == 0 )) && [[ -f "$PHASE_REPORTS/$base.a$attempt.md" ]] \
&& grep -q "no final assistant message" "$PHASE_REPORTS/$base.a$attempt.md"; then
if [[ -n "$last_session" ]] && recover_report "$PHASE_REPORTS/$base.a$attempt.md" "$last_session"; then
warn "stream lost the final message — report recovered from ${last_session##*/}"
PROGRESS_RC=0
fi
fi
errors="" errors=""
if (( CHILD_RC != 0 )); then if (( CHILD_RC != 0 )); then
warn "child pi exited with code $CHILD_RC — see $PHASE_REPORTS/$base.a$attempt.err"
errors+="[child pi exited with code $CHILD_RC]"$'\n'"$(tail -c 4000 "$PHASE_REPORTS/$base.a$attempt.err" 2>/dev/null)" errors+="[child pi exited with code $CHILD_RC]"$'\n'"$(tail -c 4000 "$PHASE_REPORTS/$base.a$attempt.err" 2>/dev/null)"
fi fi
if (( PROGRESS_RC != 0 )); then
warn "child run ended without a clean final report — see $PHASE_REPORTS/$base.a$attempt.md"
errors+="[child run ended without a clean final report]"$'\n'"$(tail -c 4000 "$PHASE_REPORTS/$base.a$attempt.err" 2>/dev/null)"
fi
if ! run_validation "$PHASE_REPORTS/$base.a$attempt.validate"; then if ! run_validation "$PHASE_REPORTS/$base.a$attempt.validate"; then
warn ".agent/validate.sh FAILED — see $PHASE_REPORTS/$base.a$attempt.validate"
errors+="[.agent/validate.sh FAILED]"$'\n'"$(tail -n 120 "$PHASE_REPORTS/$base.a$attempt.validate" 2>/dev/null)" errors+="[.agent/validate.sh FAILED]"$'\n'"$(tail -n 120 "$PHASE_REPORTS/$base.a$attempt.validate" 2>/dev/null)"
fi fi
@@ -178,8 +254,17 @@ execute_phase() {
attempt=$(( attempt + 1 )) attempt=$(( attempt + 1 ))
done done
echo "✗ $phase FAILED after $MAX_FIX_ATTEMPTS attempts — left in $PHASE_TODO/." >&2 {
echo " logs: $PHASE_REPORTS/$base.a*.{md,err,validate}" >&2 echo "✗ $phase FAILED after $MAX_FIX_ATTEMPTS attempts — left in $PHASE_TODO/."
echo " resume: pi --session-dir $PHASE_SESSIONS -c" >&2 echo " last errors:"
printf '%s\n' "$errors" | tail -n 40 | sed 's/^/ /'
echo " logs: $PHASE_REPORTS/$base.a*.{md,err,validate}"
if [[ -n "${last_session:-}" && -f "${last_session:-}" ]]; then
echo " resume: re-run this script (it auto-resumes the failed session), or manually:"
echo " pi --session $last_session -c \"review the failures above, fix them, re-validate\""
else
echo " resume: re-run this script to retry automatically"
fi
} >&2
return 1 return 1
} }
+114 -14
View File
@@ -6,6 +6,15 @@
// //
// Quiet mode still writes the report file (for harness logs); it only // Quiet mode still writes the report file (for harness logs); it only
// suppresses the progress display. // suppresses the progress display.
//
// Shows: tool calls, assistant text, thinking indicators, context
// compaction, and provider auto-retries.
//
// Exit code: 0 for a clean run (agent_end seen, no model errors, final
// assistant text received). 1 when the stream ended without agent_end
// (child crashed or was killed), the model reported an error, or no final
// assistant text was produced. `pi --mode json` itself exits 0 even on
// model errors, so the harness relies on this exit code to detect failures.
import { writeFileSync } from "node:fs"; import { writeFileSync } from "node:fs";
@@ -95,12 +104,39 @@ let sessionId = "";
let lastUsage = null; let lastUsage = null;
let lastAssistantText = ""; let lastAssistantText = "";
let sawError = false; let sawError = false;
let sawAgentEnd = false;
let thinkingSince = null;
const failures = [];
// Streaming deltas (text / thinking / tool-call args) arrive inside
// message_update.assistantMessageEvent; only thinking is surfaced live.
function handleUpdate(ame) {
if (!ame || typeof ame !== "object") return;
switch (ame.type) {
case "thinking_start":
thinkingSince = Date.now();
out(" " + c.dim("◐ thinking…"));
break;
case "thinking_end":
if (thinkingSince !== null) {
const secs = Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
out(c.dim(` ◑ thought for ${secs}s`));
thinkingSince = null;
}
break;
default:
break;
}
}
function handle(ev) { function handle(ev) {
switch (ev.type) { switch (ev.type) {
case "session": case "session":
sessionId = ev.id ?? ""; sessionId = ev.id ?? "";
break; break;
case "agent_end":
sawAgentEnd = true;
break;
case "tool_execution_start": case "tool_execution_start":
out(" " + c.cyan("⏺ " + ev.toolName) + " " + c.dim(summarizeTool(ev.toolName, ev.args))); out(" " + c.cyan("⏺ " + ev.toolName) + " " + c.dim(summarizeTool(ev.toolName, ev.args)));
break; break;
@@ -109,52 +145,104 @@ function handle(ev) {
break; break;
case "message_update": case "message_update":
if (ev.usage) lastUsage = ev.usage; if (ev.usage) lastUsage = ev.usage;
handleUpdate(ev.assistantMessageEvent);
break; break;
case "message_end": { case "message_end": {
const m = ev.message ?? {}; const m = ev.message ?? {};
if (m.role === "assistant") { if (m.role === "assistant") {
const text = textOf(m); // Trim so trailing newlines in model output don't render as blank
// lines after the message.
const text = textOf(m).trim();
if (text) { if (text) {
lastAssistantText = text; lastAssistantText = text;
out(wrap(text, 2)); out(wrap(text, 2));
} }
if (m.stopReason === "error") { if (m.stopReason === "error" || m.stopReason === "aborted") {
sawError = true; sawError = true;
out(c.red(" ✗ " + (m.errorMessage ?? "assistant error"))); failures.push(`assistant ${m.stopReason}: ${m.errorMessage ?? ""}`);
out(c.red(" ✗ " + (m.errorMessage ?? `assistant ${m.stopReason}`)));
} }
} }
break; break;
} }
case "compaction_start":
out(" " + c.yellow("⧉ compacting context (" + (ev.reason ?? "auto") + ")…"));
break;
case "compaction_end":
if (ev.aborted || ev.errorMessage) {
sawError = true;
failures.push(`compaction ${ev.aborted ? "aborted" : "failed"}: ${ev.errorMessage ?? ""}`);
out(
c.red(
" ✗ compaction " + (ev.aborted ? "aborted" : "failed") + (ev.errorMessage ? ": " + ev.errorMessage : ""),
),
);
} else {
out(c.dim(" ✓ context compacted"));
}
break;
case "auto_retry_start":
out(
c.yellow(
` ↻ provider error, retrying ${ev.attempt}/${ev.maxAttempts} in ${Math.round((ev.delayMs ?? 0) / 1000)}s: ${truncate(
ev.errorMessage ?? "",
100,
)}`,
),
);
break;
case "auto_retry_end":
if (ev.success) {
out(c.dim(" ✓ retry succeeded"));
} else {
sawError = true;
failures.push(`retries exhausted: ${ev.finalError ?? ""}`);
out(c.red(" ✗ retries exhausted: " + truncate(ev.finalError ?? "", 120)));
}
break;
default: default:
break; break;
} }
} }
function processLine(line) {
line = line.trim();
if (!line) return;
let ev;
try {
ev = JSON.parse(line);
} catch {
out(c.yellow(" ? " + truncate(line, 120)));
return;
}
handle(ev);
}
let buf = ""; let buf = "";
process.stdin.setEncoding("utf8"); process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { process.stdin.on("data", (chunk) => {
buf += chunk; buf += chunk;
let idx; let idx;
while ((idx = buf.indexOf("\n")) >= 0) { while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim(); const line = buf.slice(0, idx);
buf = buf.slice(idx + 1); buf = buf.slice(idx + 1);
if (!line) continue; processLine(line);
let ev;
try {
ev = JSON.parse(line);
} catch {
out(c.yellow(" ? " + truncate(line, 120)));
continue;
}
handle(ev);
} }
}); });
process.stdin.on("end", () => { process.stdin.on("end", () => {
// A trailing line without a final newline (child killed mid-flush) still
// counts — process whatever is left in the buffer.
if (buf.trim()) {
processLine(buf);
buf = "";
}
writeFileSync( writeFileSync(
reportPath, reportPath,
lastAssistantText ? lastAssistantText + "\n" : "(no final assistant message — see the .err log)\n", lastAssistantText ? lastAssistantText + "\n" : "(no final assistant message — see the .err log)\n",
); );
const u = lastUsage ?? {}; const u = lastUsage ?? {};
const bits = []; const bits = [];
if (u.input) bits.push(`↑${u.input}`); if (u.input) bits.push(`↑${u.input}`);
@@ -162,5 +250,17 @@ process.stdin.on("end", () => {
if (u.cacheRead) bits.push(`R${u.cacheRead}`); if (u.cacheRead) bits.push(`R${u.cacheRead}`);
if (sessionId) bits.push("session " + String(sessionId).slice(0, 8)); if (sessionId) bits.push("session " + String(sessionId).slice(0, 8));
if (bits.length) out(c.dim(" · " + bits.join(" "))); if (bits.length) out(c.dim(" · " + bits.join(" ")));
out(sawError ? c.red(" · child finished with errors") : c.green(" · child done"));
if (!sawAgentEnd) {
failures.push("stream ended without agent_end (child crashed or was killed)");
out(c.red(" ✗ stream ended without agent_end — child crashed or was killed (see .err log)"));
} else if (sawError) {
out(c.red(" ✗ child finished with errors: " + failures.join("; ")));
} else if (!lastAssistantText) {
failures.push("no final assistant text");
out(c.red(" ✗ no final assistant text — no report available (see .err log)"));
} else {
out(c.green(" · child done"));
}
process.exitCode = sawError || !sawAgentEnd || !lastAssistantText ? 1 : 0;
}); });
+12 -1
View File
@@ -11,6 +11,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib.sh" source "$SCRIPT_DIR/lib.sh"
# Make interruptions visible: state stays in .agent/phases/todo, and the
# failed executor's session is still resumable on the next run.
trap 'echo; echo "✗ ERROR: interrupted (SIGINT) — ${phase:-this phase} is left in $PHASE_TODO/; re-run to continue" >&2; exit 130' INT
trap 'echo; echo "✗ ERROR: interrupted (SIGTERM) — ${phase:-this phase} is left in $PHASE_TODO/; re-run to continue" >&2; exit 143' TERM
cd "$(find_root)" || die "no .agent/phases/todo found in this or parent directories (run /to-phase or /audit-create first)" cd "$(find_root)" || die "no .agent/phases/todo found in this or parent directories (run /to-phase or /audit-create first)"
phase="${1:-}" phase="${1:-}"
@@ -27,4 +32,10 @@ else
fi fi
build_pi_args build_pi_args
execute_phase "$phase" if execute_phase "$phase"; then
exit 0
fi
echo "✗ ERROR: phase $phase FAILED after $MAX_FIX_ATTEMPTS attempts" >&2
echo " reports: $PHASE_REPORTS/${phase%.md}.a*.{md,err,validate}" >&2
echo " resume: re-run this script — the failed executor session is resumed automatically" >&2
exit 1