- 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'
271 lines
11 KiB
Bash
Executable File
271 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# lib.sh — shared logic for the phased-execution skill.
|
|
# Sourced by run-phase.sh and auto-phase.sh. Not meant to be run directly.
|
|
#
|
|
# Phase state lives in files, not chat context:
|
|
# .agent/PLAN.md master plan, LOCKED DECISIONS (binding)
|
|
# .agent/phases/todo/ pending phases, NN_name.md, sorted = execution order
|
|
# .agent/phases/complete/ finished phases
|
|
# .agent/reports/ per-phase executor reports, stderr, validation logs
|
|
# .agent/phase-sessions/ child pi session files (resumable fixers)
|
|
#
|
|
# The pass/fail gate is .agent/validate.sh. A phase only moves to complete/
|
|
# after the child executor exits 0 AND validation passes.
|
|
|
|
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
EXECUTOR_PROMPT_FILE="$SKILL_DIR/assets/executor-prompt.md"
|
|
|
|
PHASE_TODO=".agent/phases/todo"
|
|
PHASE_DONE=".agent/phases/complete"
|
|
PHASE_REPORTS=".agent/reports"
|
|
PHASE_SESSIONS=".agent/phase-sessions"
|
|
MAX_FIX_ATTEMPTS="${MAX_FIX_ATTEMPTS:-3}"
|
|
|
|
warn() { echo "⚠ $*" >&2; }
|
|
die() { echo "✗ ERROR: $*" >&2; exit 1; }
|
|
|
|
# --- project root -------------------------------------------------------------
|
|
# Walk up from $PWD to the nearest directory containing .agent/phases/todo.
|
|
find_root() {
|
|
local d
|
|
d="$(pwd)"
|
|
while :; do
|
|
if [[ -d "$d/.agent/phases/todo" ]]; then printf '%s\n' "$d"; return 0; fi
|
|
[[ "$d" == "/" ]] && return 1
|
|
d="$(dirname "$d")"
|
|
done
|
|
}
|
|
|
|
# --- phase selection ----------------------------------------------------------
|
|
# First pending phase (alphanumerical sort), or empty when none remain.
|
|
next_phase() {
|
|
( cd "$PHASE_TODO" 2>/dev/null && ls -1 | grep -E '^[0-9]' | sort | head -n1 ) || true
|
|
}
|
|
|
|
# --- child pi arguments -------------------------------------------------------
|
|
build_pi_args() {
|
|
PI_ARGS=()
|
|
[[ -n "${PHASE_MODEL:-}" ]] && PI_ARGS+=(--model "$PHASE_MODEL")
|
|
[[ -n "${PHASE_THINKING:-}" ]] && PI_ARGS+=(--thinking "$PHASE_THINKING")
|
|
if [[ "${PI_TRUST:-0}" == "1" ]]; then
|
|
PI_ARGS+=(--approve) # load project .pi/ settings, skills, extensions
|
|
else
|
|
PI_ARGS+=(--no-approve) # deterministic: global config + AGENTS.md only
|
|
fi
|
|
}
|
|
|
|
# Most recently modified child session file (or empty).
|
|
latest_child_session() {
|
|
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 ------------------------------------------------------------------
|
|
first_prompt() {
|
|
local phase="$1"
|
|
[[ -f "$EXECUTOR_PROMPT_FILE" ]] || die "missing $EXECUTOR_PROMPT_FILE"
|
|
sed "s|{{PHASE}}|$phase|g" "$EXECUTOR_PROMPT_FILE"
|
|
}
|
|
|
|
fix_prompt() {
|
|
local errors="$1"
|
|
{
|
|
echo "Your previous attempt at this phase was rejected by the harness."
|
|
echo "The failures from the last attempt are below. Review them, fix the code, and re-run the full test suite and linter until everything is green. Do not start other phases' work."
|
|
echo
|
|
echo "Failure output (may be truncated):"
|
|
echo '```'
|
|
printf '%s\n' "$errors" | tail -c 6000
|
|
echo '```'
|
|
echo
|
|
echo "When everything is green, reply with the same report as before (at most 15 lines: what was fixed, test/lint/coverage results, notable decisions, next pending phase)."
|
|
}
|
|
}
|
|
|
|
# --- child executor -----------------------------------------------------------
|
|
# run_child <phase> <attempt> <prompt> [resume-session]
|
|
# Attempt 1: fresh session in .agent/phase-sessions/.
|
|
# Attempt N>1: resumes the given session file — the failed executor's own
|
|
# session, tracked by execute_phase (so retries keep its work). When no
|
|
# session was captured (or FRESH_FIX=1) a fresh ephemeral session runs with
|
|
# the failure context instead.
|
|
# 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
|
|
# QUIET=1 suppresses the progress display (report file is still written).
|
|
# 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() {
|
|
local phase="$1" attempt="$2" prompt="$3" resume_session="${4:-}"
|
|
local base="${phase%.md}"
|
|
local out="$PHASE_REPORTS/$base.a$attempt.md"
|
|
local errf="$PHASE_REPORTS/$base.a$attempt.err"
|
|
local PROGRESS=(node "$SKILL_DIR/scripts/progress.mjs" "$out")
|
|
[[ "${QUIET:-0}" == "1" ]] && PROGRESS+=(--quiet)
|
|
|
|
if (( attempt == 1 )); then
|
|
_run_pi_pipeline "$errf" pi "${PI_ARGS[@]}" --session-dir "$PHASE_SESSIONS" --name "$base" --mode json "$prompt"
|
|
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
|
|
echo " (resuming failed executor session: ${resume_session##*/})"
|
|
_run_pi_pipeline "$errf" pi "${PI_ARGS[@]}" --session "$resume_session" --mode json "$prompt"
|
|
fi
|
|
}
|
|
|
|
# --- validation gate ----------------------------------------------------------
|
|
ensure_validate() {
|
|
if [[ ! -f .agent/validate.sh ]]; then
|
|
cp "$SKILL_DIR/assets/validate.sh" .agent/validate.sh
|
|
chmod +x .agent/validate.sh
|
|
warn "no .agent/validate.sh found — created it from the skill template."
|
|
warn "adapt it to this project's real test/lint/coverage commands; it is the pass/fail gate for every phase."
|
|
fi
|
|
}
|
|
|
|
# run_validation <logfile>; returns 0 iff .agent/validate.sh exits 0.
|
|
run_validation() {
|
|
bash .agent/validate.sh >"$1" 2>&1
|
|
}
|
|
|
|
# --- one phase, with bounded fixer retries ------------------------------------
|
|
# execute_phase <phase-file>
|
|
# Returns 0 and moves the phase to complete/ on success; returns 1 after
|
|
# MAX_FIX_ATTEMPTS failed attempts (phase file is left in todo/).
|
|
execute_phase() {
|
|
local phase="$1"
|
|
local base="${phase%.md}"
|
|
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)"
|
|
mkdir -p "$PHASE_DONE" "$PHASE_REPORTS" "$PHASE_SESSIONS"
|
|
ensure_validate
|
|
|
|
while (( attempt <= MAX_FIX_ATTEMPTS )); do
|
|
echo "━━ $phase — attempt $attempt/$MAX_FIX_ATTEMPTS ━━"
|
|
pre="$(latest_child_session)"
|
|
if (( attempt == 1 )); then
|
|
run_child "$phase" 1 "$(first_prompt "$phase")"
|
|
else
|
|
run_child "$phase" "$attempt" "$(fix_prompt "$errors")" "$last_session"
|
|
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=""
|
|
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)"
|
|
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
|
|
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)"
|
|
fi
|
|
|
|
if [[ -z "$errors" ]]; then
|
|
mv -f "$PHASE_TODO/$phase" "$PHASE_DONE/$phase"
|
|
echo "✓ $phase → complete"
|
|
if [[ "${PHASE_COMMIT:-0}" == "1" ]]; then
|
|
if git add -A 2>/dev/null && git commit --no-gpg-sign -m "phase: $phase" >/dev/null 2>&1; then
|
|
echo " (committed)"
|
|
else
|
|
warn "git commit failed (continuing)"
|
|
fi
|
|
fi
|
|
echo "── executor report ──"
|
|
cat "$PHASE_REPORTS/$base.a$attempt.md"
|
|
return 0
|
|
fi
|
|
attempt=$(( attempt + 1 ))
|
|
done
|
|
|
|
{
|
|
echo "✗ $phase FAILED after $MAX_FIX_ATTEMPTS attempts — left in $PHASE_TODO/."
|
|
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
|
|
}
|