Children never commit (their prompts forbid git add/commit, overriding project instructions) so the commit is deterministic. At the phase commit point (00_phase.md final pass) the harness makes ONE atomic commit: the phase's code changes, the todo→complete file move, and the executor reports, together. - Scoped staging: a worktree snapshot taken at the phase's first unit (phase-sessions/dirty-<phase>, always kept) is subtracted, so the owner's pre-existing uncommitted work is left alone; runtime artifacts are never staged. The commit prints exactly what went in. - Verified: the moved phase file (and report, unless gitignored) must be in the index before committing. - Loud on failure: a commit failure prints the git error + hand-fix and stops the run; the phase stays complete, the miss is never swept into a later phase. - PHASE_COMMIT_SUBJECT (default 'phase: <phase>'); the executor's final report becomes the commit body; --no-gpg-sign always passed. - PHASE_COMMIT defaults to 1: completed phases are committed by default. Explicit PHASE_COMMIT=0 opts out with a loud warning that the phase is complete but uncommitted — a completed phase is never left uncommitted silently (that gap let four phases pile up uncommitted in brain_of_reese).
580 lines
24 KiB
Bash
Executable File
580 lines
24 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# lib.sh — shared logic for the phased-execution skill.
|
||
# Sourced by run-task.sh, run-phase.sh, and auto-phase.sh. Not meant to be run directly.
|
||
#
|
||
# Phase state lives in files, not chat context:
|
||
# .agents/PLAN.md master plan, LOCKED DECISIONS (binding)
|
||
# .agents/phases/todo/NN_name/ pending phase: 00_phase.md (overview) + NN_task.md task files
|
||
# .agents/phases/todo/NN_name.md legacy single-file phase (still executable)
|
||
# .agents/phases/complete/ finished phases — mirrors the todo/ layout
|
||
# .agents/reports/ per-task executor reports, stderr, validation logs
|
||
# .agents/phase-sessions/ child pi session files (resumable fixers)
|
||
#
|
||
# The unit of execution is the TASK: each task file runs in its own pi
|
||
# subprocess (fresh context) with bounded fixer retries, and
|
||
# .agents/validate.sh runs after EVERY task. A unit only moves to complete/
|
||
# after the child exits 0, the child's stream ends with a clean final
|
||
# report, and validation passes. When all of a phase's tasks are done,
|
||
# 00_phase.md runs as the phase's final pass (any remaining inline work +
|
||
# completion criteria + phase-level verification); moving it completes the
|
||
# phase and is the commit point. Children never commit — by default
|
||
# (PHASE_COMMIT=1) the harness makes ONE atomic commit per completed phase
|
||
# (code changes + the file move + the executor reports), scoped so the
|
||
# owner's pre-existing worktree changes are left alone (see commit_phase).
|
||
|
||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
EXECUTOR_PROMPT_FILE="$SKILL_DIR/assets/executor-prompt.md"
|
||
TASK_EXECUTOR_PROMPT_FILE="$SKILL_DIR/assets/task-executor-prompt.md"
|
||
PHASE_FINAL_PROMPT_FILE="$SKILL_DIR/assets/phase-final-prompt.md"
|
||
|
||
PHASE_TODO=".agents/phases/todo"
|
||
PHASE_DONE=".agents/phases/complete"
|
||
PHASE_REPORTS=".agents/reports"
|
||
PHASE_SESSIONS=".agents/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 .agents/phases/todo.
|
||
find_root() {
|
||
local d
|
||
d="$(pwd)"
|
||
while :; do
|
||
if [[ -d "$d/.agents/phases/todo" ]]; then printf '%s\n' "$d"; return 0; fi
|
||
[[ "$d" == "/" ]] && return 1
|
||
d="$(dirname "$d")"
|
||
done
|
||
}
|
||
|
||
# --- unit selection -------------------------------------------------------------
|
||
# A unit is the smallest schedulable piece of work, referenced relative to
|
||
# .agents/phases/todo/:
|
||
# directory phase → each task file "NN_name/NN_task.md" (sort order), then
|
||
# the phase overview "NN_name/00_phase.md" as the final pass
|
||
# legacy flat → the phase file itself, "NN_name.md"
|
||
|
||
# Pending phase entries in todo/ (phase directories or legacy .md files), in execution order.
|
||
phase_entries() {
|
||
( cd "$PHASE_TODO" 2>/dev/null && ls -1 | grep -E '^[0-9]' | sort ) || true
|
||
}
|
||
|
||
# First pending unit of one phase entry, or empty when the phase is done.
|
||
phase_next_unit() {
|
||
local p="$1" t
|
||
if [[ -d "$PHASE_TODO/$p" ]]; then
|
||
t="$(ls -1 "$PHASE_TODO/$p" 2>/dev/null | grep -E '^[0-9].*\.md$' | grep -vE '^00_phase\.md$' | sort | head -n1)"
|
||
if [[ -n "$t" ]]; then printf '%s/%s\n' "$p" "$t"; return 0; fi
|
||
if [[ -f "$PHASE_TODO/$p/00_phase.md" ]]; then printf '%s/00_phase.md\n' "$p"; return 0; fi
|
||
elif [[ -f "$PHASE_TODO/$p" ]]; then
|
||
printf '%s\n' "$p"
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# Next pending unit in the whole pipeline, or empty when everything is done.
|
||
next_unit() {
|
||
local p u
|
||
for p in $(phase_entries); do
|
||
u="$(phase_next_unit "$p")"
|
||
if [[ -n "$u" ]]; then printf '%s\n' "$u"; return 0; fi
|
||
done
|
||
return 0
|
||
}
|
||
|
||
# Count of pending units across all phases (task files + 00_phase.md + legacy files).
|
||
count_units() {
|
||
local p n=0
|
||
for p in $(phase_entries); do
|
||
if [[ -d "$PHASE_TODO/$p" ]]; then
|
||
n=$(( n + $(ls -1 "$PHASE_TODO/$p" 2>/dev/null | grep -cE '\.md$') ))
|
||
elif [[ -f "$PHASE_TODO/$p" ]]; then
|
||
n=$(( n + 1 ))
|
||
fi
|
||
done
|
||
printf '%s\n' "$n"
|
||
}
|
||
|
||
# Name used in reports/sessions: "NN_name__NN_task" (dir phases) or "NN_name" (flat).
|
||
unit_base() {
|
||
local u="${1%.md}"
|
||
printf '%s\n' "${u//\//__}"
|
||
}
|
||
|
||
# Phase name for a unit (both forms).
|
||
unit_phase() {
|
||
local u="${1%.md}"
|
||
printf '%s\n' "${u%%/*}"
|
||
}
|
||
|
||
# Reports directory for a unit: per-phase subdir for dir phases, flat otherwise.
|
||
unit_report_dir() {
|
||
local u="$1"
|
||
if [[ "$u" == */* ]]; then
|
||
printf '%s/%s\n' "$PHASE_REPORTS" "$(unit_phase "$u")"
|
||
else
|
||
printf '%s\n' "$PHASE_REPORTS"
|
||
fi
|
||
}
|
||
|
||
# Report/log path for a unit attempt: <dir>/<base>.a<attempt>.<ext>.
|
||
unit_report() {
|
||
local u="$1" attempt="$2" ext="$3"
|
||
printf '%s/%s.a%s.%s\n' "$(unit_report_dir "$u")" "$(unit_base "$u")" "$attempt" "$ext"
|
||
}
|
||
|
||
# Phase-end unit (the commit point): 00_phase.md, or the whole file for legacy.
|
||
is_phase_end() {
|
||
local u="$1"
|
||
if [[ "$u" == */* ]]; then [[ "${u##*/}" == "00_phase.md" ]]; else return 0; fi
|
||
}
|
||
|
||
# Move a completed unit from todo/ to complete/ (same relative path).
|
||
move_unit() {
|
||
local u="$1" p t
|
||
if [[ "$u" == */* ]]; then
|
||
p="${u%%/*}"; t="${u##*/}"
|
||
mkdir -p "$PHASE_DONE/$p"
|
||
mv -f "$PHASE_TODO/$u" "$PHASE_DONE/$p/$t"
|
||
if [[ -z "$(ls -A "$PHASE_TODO/$p" 2>/dev/null)" ]]; then rmdir "$PHASE_TODO/$p"; fi
|
||
else
|
||
mkdir -p "$PHASE_DONE"
|
||
mv -f "$PHASE_TODO/$u" "$PHASE_DONE/$u"
|
||
fi
|
||
}
|
||
|
||
# --- 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-attempt prompt for a unit. Directory tasks render the task executor
|
||
# prompt; the phase overview renders the phase-final prompt; legacy flat
|
||
# phase files render the original phase executor prompt.
|
||
first_prompt() {
|
||
local unit="$1" p t
|
||
if [[ "$unit" == */* ]]; then
|
||
p="${unit%%/*}"; t="${unit##*/}"
|
||
if [[ "$t" == "00_phase.md" ]]; then
|
||
[[ -f "$PHASE_FINAL_PROMPT_FILE" ]] || die "missing $PHASE_FINAL_PROMPT_FILE"
|
||
sed "s|{{PHASE}}|$p|g" "$PHASE_FINAL_PROMPT_FILE"
|
||
else
|
||
[[ -f "$TASK_EXECUTOR_PROMPT_FILE" ]] || die "missing $TASK_EXECUTOR_PROMPT_FILE"
|
||
sed "s|{{PHASE}}|$p|g; s|{{TASK}}|$t|g" "$TASK_EXECUTOR_PROMPT_FILE"
|
||
fi
|
||
else
|
||
[[ -f "$EXECUTOR_PROMPT_FILE" ]] || die "missing $EXECUTOR_PROMPT_FILE"
|
||
sed "s|{{PHASE}}|$unit|g" "$EXECUTOR_PROMPT_FILE"
|
||
fi
|
||
}
|
||
|
||
fix_prompt() {
|
||
local errors="$1"
|
||
{
|
||
echo "Your previous attempt at this task 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 tasks' 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 task)."
|
||
}
|
||
}
|
||
|
||
# --- child executor -----------------------------------------------------------
|
||
# run_child <unit> <attempt> <prompt> [resume-session]
|
||
# Attempt 1: fresh session in .agents/phase-sessions/.
|
||
# Attempt N>1: resumes the given session file — the failed executor's own
|
||
# session, tracked by execute_unit (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: unit_report <unit> <attempt> md
|
||
# Child stderr → unit_report <unit> <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) — unit 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) — unit is left in $PHASE_TODO/; re-run to continue" >&2
|
||
exit 143
|
||
fi
|
||
}
|
||
|
||
run_child() {
|
||
local unit="$1" attempt="$2" prompt="$3" resume_session="${4:-}"
|
||
local base out errf
|
||
base="$(unit_base "$unit")"
|
||
out="$(unit_report "$unit" "$attempt" md)"
|
||
errf="$(unit_report "$unit" "$attempt" err)"
|
||
mkdir -p "$(dirname "$out")"
|
||
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 .agents/validate.sh ]]; then
|
||
cp "$SKILL_DIR/assets/validate.sh" .agents/validate.sh
|
||
chmod +x .agents/validate.sh
|
||
warn "no .agents/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 after every task."
|
||
fi
|
||
}
|
||
|
||
# run_validation <logfile>; returns 0 iff .agents/validate.sh exits 0.
|
||
run_validation() {
|
||
bash .agents/validate.sh >"$1" 2>&1
|
||
}
|
||
|
||
# --- phase commit -------------------------------------------------------------
|
||
# Commit ownership: child executors NEVER commit (their prompts forbid it,
|
||
# overriding any project instruction to commit per task/phase). By default
|
||
# (PHASE_COMMIT=1) the harness therefore makes ONE atomic commit per
|
||
# completed phase — the phase's code changes, the todo→complete file move,
|
||
# and the executor reports, together — at the phase's commit point.
|
||
#
|
||
# Staging is scoped so the owner's unrelated work is not swept in:
|
||
# stage = (everything dirty now)
|
||
# − (what was already dirty when the phase's first unit started —
|
||
# snapshot $PHASE_SESSIONS/dirty-<phase>, taken once per phase)
|
||
# − (pipeline runtime artifacts: phase-sessions/, pipeline.log)
|
||
# Work the owner dirtied mid-phase is indistinguishable from phase work and
|
||
# IS committed — the commit output prints exactly what went in.
|
||
|
||
# Snapshot the worktree's dirty state at the phase's first unit (no-op
|
||
# outside a git work tree, and when the phase already has a snapshot — a
|
||
# resumed phase keeps the one taken at its start).
|
||
phase_dirty_snapshot() {
|
||
local phase="$1" f
|
||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
|
||
f="$PHASE_SESSIONS/dirty-$phase"
|
||
if [[ ! -f "$f" ]]; then
|
||
git status --porcelain >"$f" 2>/dev/null || : >"$f"
|
||
fi
|
||
}
|
||
|
||
# commit_phase <unit> <report-file>
|
||
# Returns 0 on success, when there is nothing left to commit, or outside a
|
||
# git work tree. Returns 1 (after printing a ✗ ERROR block with the git
|
||
# error and the hand-fix command) when the phase's own artifacts could not
|
||
# be committed. The phase stays COMPLETE either way — the work passed
|
||
# validation; the caller stops the run so a commit miss is visible, never
|
||
# retried as a task and never swept into a later phase's commit.
|
||
commit_phase() {
|
||
local unit="$1" report="${2:-}"
|
||
local phase pre curf paths lits moved staged addout commitout subject msgf
|
||
phase="$(unit_phase "$unit")"
|
||
|
||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||
warn "not a git work tree — no phase commit for $phase"
|
||
return 0
|
||
fi
|
||
pre="$PHASE_SESSIONS/dirty-$phase"
|
||
|
||
# Everything dirty now: tracked changes vs HEAD + untracked non-ignored.
|
||
curf="$(mktemp)"
|
||
{ git diff --name-only HEAD; git ls-files --others --exclude-standard; } | sort -u >"$curf"
|
||
|
||
# Quick exit: the phase's artifacts are already committed (moved file
|
||
# tracked and clean — e.g. a duplicate commit attempt after the snapshot
|
||
# was cleaned up) — never stage again, owner WIP stays untouched.
|
||
if git ls-files --error-unmatch -- "$PHASE_DONE/$unit" >/dev/null 2>&1 \
|
||
&& ! grep -qx -- "$PHASE_DONE/$unit" "$curf"; then
|
||
rm -f "$curf"
|
||
echo " (nothing to commit — the phase is already fully committed)"
|
||
return 0
|
||
fi
|
||
|
||
# Subtract pre-phase owner WIP (porcelain lines are "XY <path>") and the
|
||
# pipeline's own runtime artifacts.
|
||
paths="$(mktemp)"
|
||
if [[ -f "$pre" ]]; then
|
||
comm -23 "$curf" <(cut -c4- "$pre" | grep -v '^[[:space:]]*$' | sort -u) \
|
||
| grep -vE '^\.agents/(phase-sessions/|pipeline\.log$)' >"$paths" || true
|
||
else
|
||
grep -vE '^\.agents/(phase-sessions/|pipeline\.log$)' "$curf" >"$paths" || true
|
||
fi
|
||
rm -f "$curf"
|
||
|
||
if [[ ! -s "$paths" ]]; then
|
||
rm -f "$paths" "$pre"
|
||
echo " (nothing to commit — the phase is already fully committed)"
|
||
return 0
|
||
fi
|
||
|
||
# :(literal) keeps file names with glob characters ([?* ) from being read
|
||
# as pathspec patterns.
|
||
lits="$(mktemp)"
|
||
sed 's|^|:(literal)|' "$paths" >"$lits"
|
||
if ! addout="$(git add -A --pathspec-from-file="$lits" 2>&1)"; then
|
||
rm -f "$paths" "$lits"
|
||
echo "✗ ERROR: phase commit for $phase — staging failed:" >&2
|
||
printf '%s\n' "$addout" | sed 's/^/ /' >&2
|
||
return 1
|
||
fi
|
||
rm -f "$lits"
|
||
|
||
# Verify the artifacts that used to go missing: the moved phase file must
|
||
# be in the index (hard fail), and the executor report unless the project
|
||
# deliberately gitignores reports.
|
||
moved="$PHASE_DONE/$unit"
|
||
staged="$(git -c core.quotepath=off diff --cached --name-only -z | tr '\0' '\n')"
|
||
if ! grep -qx -- "$moved" <<<"$staged"; then
|
||
git reset -q
|
||
rm -f "$paths"
|
||
echo "✗ ERROR: phase commit for $phase — the moved phase file is not in the index ($moved)." >&2
|
||
if git check-ignore -q -- "$moved" 2>/dev/null; then
|
||
echo " your .gitignore excludes it — .agents/phases must be tracked (SKILL.md setup notes)." >&2
|
||
fi
|
||
echo " the work is complete and uncommitted; stage and commit it by hand." >&2
|
||
return 1
|
||
fi
|
||
if [[ -n "$report" && -f "$report" ]] && ! grep -qx -- "$report" <<<"$staged" \
|
||
&& ! git check-ignore -q -- "$report" 2>/dev/null; then
|
||
git reset -q
|
||
rm -f "$paths"
|
||
echo "✗ ERROR: phase commit for $phase — the executor report is not in the index ($report)." >&2
|
||
echo " the work is complete and uncommitted; stage and commit it by hand." >&2
|
||
return 1
|
||
fi
|
||
rm -f "$paths"
|
||
|
||
# Subject: PHASE_COMMIT_SUBJECT with {{PHASE}} substituted (default
|
||
# "phase: <phase>"); the executor's final report becomes the body.
|
||
# The default subject lives in a variable: a brace literal inside the
|
||
# ${var:-default} word would terminate the expansion early (bash does not
|
||
# nest plain braces).
|
||
local default_subject="phase: {{PHASE}}"
|
||
subject="${PHASE_COMMIT_SUBJECT:-$default_subject}"
|
||
subject="${subject//\{\{PHASE\}\}/$phase}"
|
||
msgf="$(mktemp)"
|
||
{
|
||
printf '%s\n' "$subject"
|
||
printf '\n'
|
||
[[ -n "$report" && -f "$report" ]] && sed -n '1,40p' "$report"
|
||
} >"$msgf"
|
||
|
||
if ! commitout="$(git commit --no-gpg-sign -F "$msgf" 2>&1)"; then
|
||
rm -f "$msgf"
|
||
echo "✗ ERROR: phase commit for $phase FAILED — the phase is complete but UNCOMMITTED (the scoped changes are still staged)." >&2
|
||
printf '%s\n' "$commitout" | sed 's/^/ /' >&2
|
||
echo " finish by hand: git commit --no-gpg-sign -m 'phase: $phase'" >&2
|
||
echo " re-running the pipeline will NOT commit this phase — fix the commit first." >&2
|
||
return 1
|
||
fi
|
||
rm -f "$msgf" "$pre"
|
||
echo " (committed: $(git log -1 --oneline))"
|
||
return 0
|
||
}
|
||
|
||
# --- notifications ------------------------------------------------------------
|
||
# Send a push notification via ntfy after a unit completes. Reads
|
||
# ~/.env/pi-ntfy.env (see the ntfy skill). No-ops (silently) when notifications
|
||
# are disabled (PHASE_NOTIFY=0) or ntfy is not configured, so it never breaks
|
||
# the pipeline. Gated on PHASE_NOTIFY so a misconfigured server can't stall a
|
||
# long run; default ON when the env file is present.
|
||
notify_task() {
|
||
local unit="$1" status="$2" # status: task | phase
|
||
[[ "${PHASE_NOTIFY:-1}" == "1" ]] || return 0
|
||
local envf="${NTFY_ENV_FILE:-$HOME/.env/pi-ntfy.env}"
|
||
[[ -f "$envf" ]] || return 0
|
||
# shellcheck disable=SC1090
|
||
source "$envf"
|
||
[[ -n "${NTFY_URL:-}" && -n "${NTFY_TOKEN:-}" ]] || return 0
|
||
|
||
local title body tags
|
||
if [[ "$status" == phase ]]; then
|
||
title="Phase $(unit_phase "$unit") done"
|
||
tags="heavy_check_mark,phase-complete"
|
||
body="Phase $(unit_phase "$unit") of the pipeline completed."
|
||
else
|
||
title="Task complete"
|
||
tags="heavy_check_mark,task-complete"
|
||
body="Task delivered: $unit"
|
||
fi
|
||
curl -sS -X POST "${NTFY_URL}/${NTFY_TOPIC:-pi}" \
|
||
-H "Authorization: Bearer ${NTFY_TOKEN}" \
|
||
-H "X-Title: $title" \
|
||
-H "X-Priority: 4" \
|
||
-H "X-Tags: $tags" \
|
||
-H "X-Markdown: yes" \
|
||
-d "$body" >/dev/null 2>&1 || warn "ntfy notification failed for $unit"
|
||
}
|
||
|
||
# --- one unit (task, phase final pass, or legacy phase), with retries ---------
|
||
# execute_unit <unit>
|
||
# Returns 0 and moves the unit to complete/ on success; returns 1 after
|
||
# MAX_FIX_ATTEMPTS failed attempts (unit file is left in todo/), or 1 when
|
||
# the phase commit fails at a phase's commit point (unit stays in
|
||
# complete/ — the work is done; the commit must be finished by hand).
|
||
execute_unit() {
|
||
local unit="$1"
|
||
local base attempt=1 errors=""
|
||
local last_session="" pre post
|
||
base="$(unit_base "$unit")"
|
||
command -v node >/dev/null 2>&1 || die "node not found on PATH (needed to render task progress)"
|
||
mkdir -p "$PHASE_DONE" "$PHASE_REPORTS" "$PHASE_SESSIONS" "$(unit_report_dir "$unit")"
|
||
ensure_validate
|
||
# First unit of the phase: freeze the owner's pre-existing dirty state so
|
||
# the phase commit (commit_phase) can exclude it from staging.
|
||
phase_dirty_snapshot "$(unit_phase "$unit")"
|
||
|
||
while (( attempt <= MAX_FIX_ATTEMPTS )); do
|
||
echo "━━ $unit — attempt $attempt/$MAX_FIX_ATTEMPTS ━━"
|
||
pre="$(latest_child_session)"
|
||
if (( attempt == 1 )); then
|
||
run_child "$unit" 1 "$(first_prompt "$unit")"
|
||
else
|
||
run_child "$unit" "$attempt" "$(fix_prompt "$errors")" "$last_session"
|
||
fi
|
||
# Track which session file this attempt used, so the next attempt resumes
|
||
# exactly this unit'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 unit is not retried.
|
||
if (( CHILD_RC == 0 )) && [[ -f "$(unit_report "$unit" "$attempt" md)" ]] \
|
||
&& grep -q "no final assistant message" "$(unit_report "$unit" "$attempt" md)"; then
|
||
if [[ -n "$last_session" ]] && recover_report "$(unit_report "$unit" "$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 $(unit_report "$unit" "$attempt" err)"
|
||
errors+="[child pi exited with code $CHILD_RC]"$'\n'"$(tail -c 4000 "$(unit_report "$unit" "$attempt" err)" 2>/dev/null)"
|
||
fi
|
||
if (( PROGRESS_RC != 0 )); then
|
||
warn "child run ended without a clean final report — see $(unit_report "$unit" "$attempt" md)"
|
||
errors+="[child run ended without a clean final report]"$'\n'"$(tail -c 4000 "$(unit_report "$unit" "$attempt" err)" 2>/dev/null)"
|
||
fi
|
||
if ! run_validation "$(unit_report "$unit" "$attempt" validate)"; then
|
||
warn ".agents/validate.sh FAILED — see $(unit_report "$unit" "$attempt" validate)"
|
||
errors+="[.agents/validate.sh FAILED]"$'\n'"$(tail -n 120 "$(unit_report "$unit" "$attempt" validate)" 2>/dev/null)"
|
||
fi
|
||
|
||
if [[ -z "$errors" ]]; then
|
||
move_unit "$unit"
|
||
if is_phase_end "$unit"; then
|
||
echo "✓ $unit → complete (phase $(unit_phase "$unit") done)"
|
||
if [[ "${PHASE_COMMIT:-1}" == "1" ]]; then
|
||
if ! commit_phase "$unit" "$(unit_report "$unit" "$attempt" md)"; then
|
||
# The work is done and validated; the unit stays in complete/ and
|
||
# re-running will NOT re-execute this phase. Stop the run so the
|
||
# commit miss is visible — commit_phase printed the hand-fix.
|
||
echo " phase work is UNCOMMITTED — fix the commit first (see above); re-running continues at the next phase" >&2
|
||
return 1
|
||
fi
|
||
else
|
||
# Explicit opt-out (PHASE_COMMIT=0): respect it, but never let the
|
||
# miss be silent — a completed phase with uncommitted work used to
|
||
# pile up in the worktree unnoticed.
|
||
warn "PHASE_COMMIT=0 — phase $(unit_phase "$unit") is COMPLETE but UNCOMMITTED; its work is left in the worktree — commit it by hand"
|
||
fi
|
||
else
|
||
echo "✓ $unit → complete"
|
||
fi
|
||
echo "── executor report ──"
|
||
cat "$(unit_report "$unit" "$attempt" md)"
|
||
return 0
|
||
fi
|
||
attempt=$(( attempt + 1 ))
|
||
done
|
||
|
||
{
|
||
echo "✗ $unit FAILED after $MAX_FIX_ATTEMPTS attempts — left in $PHASE_TODO/."
|
||
echo " last errors:"
|
||
printf '%s\n' "$errors" | tail -n 40 | sed 's/^/ /'
|
||
echo " logs: $(unit_report_dir "$unit")/$(unit_base "$unit").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
|
||
}
|