Files
ducoterra b88fbaec62 feat(phased-execution): push each phase commit after it is made
The harness now pushes the phase commit right after committing it
(PHASE_PUSH=1 default, new push_phase in lib.sh): upstream when set,
else 'git push -u <first remote> <branch>'; no remote = skip with a
notice. A push failure keeps the commit local, prints the same loud
ERROR contract as a commit failure, and stops the run — the next
phase's push sweeps the unpushed commit in. PHASE_PUSH=0 opts out with
a loud notice. SKILL.md (Commits + config table + exit codes) and the
executor prompts document the new behavior.
2026-09-10 07:56:30 -04:00

627 lines
26 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, and pushes it when
# the repo has a remote (PHASE_PUSH=1; see commit_phase / push_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 + push ------------------------------------------------------
# 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, and
# pushes it right after (PHASE_PUSH=1, default — see push_phase).
#
# 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, or the push of the phase commit failed (the commit is local
# and safe — the next phase's push sweeps it in once the push works). The
# phase stays COMPLETE either way — the work passed validation; the caller
# stops the run so a commit/push 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))"
# Hand the commit to the remote (PHASE_PUSH, default on). A push failure
# returns 1 so the run stops — same contract as a commit failure.
if ! push_phase "$phase"; then
return 1
fi
return 0
}
# push_phase <phase>
# Pushes the phase commit made by commit_phase (PHASE_PUSH=1, default).
# No remote configured → notice and success (the commit stays local).
# Branch with an upstream → `git push`; without → `git push -u <first
# remote> <branch>`. On failure prints a ✗ ERROR block with the git error
# and the hand-fix command and returns 1 — the commit is local and safe,
# and the next phase's push sweeps the unpushed commit in once the push
# works.
push_phase() {
local phase="$1" remote branch cmd pushout
[[ "${PHASE_PUSH:-1}" == "1" ]] || { echo " (PHASE_PUSH=0 — commit not pushed, left local)"; return 0; }
remote="$(git remote 2>/dev/null | head -n1)"
if [[ -z "$remote" ]]; then
echo " (no git remote configured — skipping push)"
return 0
fi
branch="$(git symbolic-ref --short -q HEAD || true)"
if [[ -z "$branch" ]]; then
echo "✗ ERROR: phase push for $phase — detached HEAD, nothing to push." >&2
echo " the phase commit is local; push by hand once you are on a branch." >&2
return 1
fi
if git rev-parse --abbrev-ref --disambiguate '@{u}' >/dev/null 2>&1; then
cmd=(git push)
else
cmd=(git push -u "$remote" "$branch")
fi
if ! pushout="$("${cmd[@]}" 2>&1)"; then
echo "✗ ERROR: phase push for $phase FAILED — the phase is committed LOCALLY but NOT PUSHED." >&2
printf '%s\n' "$pushout" | sed 's/^/ /' >&2
echo " finish by hand: ${cmd[*]}" >&2
echo " re-running will NOT re-push it now — the next phase's push sweeps it in once the push works; fix the push first." >&2
return 1
fi
echo " (pushed: $(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 or its push fails at a phase's commit point (unit stays
# in complete/ — the work is done; the commit/push 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 commit/push did not complete — fix it 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
}