Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions scripts/codex-fleet/down.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,48 @@ else
echo "[codex-fleet] no tmux session named $SESSION (already down)"
fi

# [SI-15] Clean up plan-into-writable_root symlinks created at bringup time.
#
# Reads the active-plan slug + the priority plan's metadata.writable_roots,
# then unlinks every $W/openspec/plans/$slug symlink that exists. Idempotent:
# missing files / missing slugs / missing repo are all non-fatal — the
# whole block is wrapped in a tolerant `if`.
REPO_ROOT_FOR_PLAN="${CODEX_FLEET_REPO_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
ACTIVE_PLAN_FILE="$REPO_ROOT_FOR_PLAN/.codex-fleet/active-plan"
if [[ -f "$ACTIVE_PLAN_FILE" ]]; then
PLAN_SLUG_FOR_DOWN="$(tr -d '[:space:]' < "$ACTIVE_PLAN_FILE" 2>/dev/null || true)"
PLAN_JSON_FOR_DOWN="$REPO_ROOT_FOR_PLAN/openspec/plans/$PLAN_SLUG_FOR_DOWN/plan.json"
if [[ -n "$PLAN_SLUG_FOR_DOWN" && -f "$PLAN_JSON_FOR_DOWN" ]]; then
WRITABLE_ROOTS_LIST="$(PLAN_FILE="$PLAN_JSON_FOR_DOWN" python3 - <<'PY' 2>/dev/null || true
import json, os
p = os.environ.get("PLAN_FILE", "")
try:
with open(p) as f:
data = json.load(f)
except Exception:
data = {}
roots = (data.get("metadata") or {}).get("writable_roots") or []
for r in roots:
print(r)
PY
)"
while IFS= read -r writable_root; do
[[ -z "$writable_root" ]] && continue
# Skip roots that ARE the repo (staging skipped them too).
case "$writable_root" in
"$REPO_ROOT_FOR_PLAN"|"$REPO_ROOT_FOR_PLAN"/*)
continue
;;
esac
link_path="$writable_root/openspec/plans/$PLAN_SLUG_FOR_DOWN"
if [[ -L "$link_path" ]]; then
rm -f "$link_path"
echo "[codex-fleet] unlinked plan symlink: $link_path"
fi
done <<< "$WRITABLE_ROOTS_LIST"
fi
fi

if [[ "$PURGE" -eq 1 ]]; then
if [[ "$WORK_ROOT" == "/" || "$WORK_ROOT" == "$HOME" ]]; then
echo "fatal: refusing to purge work-root $WORK_ROOT (looks like a sensitive path)" >&2
Expand Down
51 changes: 51 additions & 0 deletions scripts/codex-fleet/full-bringup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ fi
[ -f "openspec/plans/$PLAN_SLUG/plan.json" ] || die "plan workspace missing: openspec/plans/$PLAN_SLUG/plan.json"
log "priority plan: $PLAN_SLUG"

# Persist the priority slug so daemons (pr-babysitter, auto-reviewer,
# plan-watcher, wake-prompt-templater) + down.sh's symlink cleanup can read
# it without re-running the newest-plan picker. Single-line file, no newline.
mkdir -p "$REPO/.codex-fleet"
printf '%s' "$PLAN_SLUG" > "$REPO/.codex-fleet/active-plan"

# 2b. Build --add-dir flags from plan metadata.writable_roots (schema:
# scripts/codex-fleet/lib/plan-meta.md). Falls back to the recodee +
# codex-fleet pair when the plan declares nothing.
Expand Down Expand Up @@ -236,6 +242,43 @@ for path in $(printf '%s\n' "$ADD_DIR_FLAGS" | awk '{for(i=1;i<=NF;i++) if($i=="
done
log "writable roots ok: $add_count root(s)"

# 2e. [SI-15] Stage the priority plan into every writable_root.
#
# When a worker pane cd's into a writable_root W (e.g. polymarket-cli),
# `colony plan status <slug>` resolves the plan workspace relative to W's
# cwd — i.e. `W/openspec/plans/<slug>/plan.json`. Without staging, that
# file does not exist outside the codex-fleet repo and the call errors
# with ENOENT (observed 2026-05-18 during the pt2 trading-edge run).
#
# Fix: for each W in metadata.writable_roots that is OUTSIDE this repo,
# `ln -sfn $REPO/openspec/plans/<slug> $W/openspec/plans/<slug>` so the
# plan workspace resolves from any cwd. Symlinks are idempotent (-sfn
# replaces an existing link without erroring) and torn down by down.sh.
#
# Roots inside REPO are skipped silently — the plan already lives there.
stage_plan_symlink() {
local writable_root="$1" slug="$2" repo="$3"
# Skip when the writable_root is the repo itself or any subpath thereof —
# the plan workspace already resolves directly at $repo/openspec/plans/<slug>.
case "$writable_root" in
"$repo"|"$repo"/*)
return 0
;;
esac
local target="$repo/openspec/plans/$slug"
local link_parent="$writable_root/openspec/plans"
local link_path="$link_parent/$slug"
mkdir -p "$link_parent" 2>/dev/null || return 1
ln -sfn "$target" "$link_path" || return 1
log "symlinked plan into writable root: $link_path"
return 0
}

for path in $(printf '%s\n' "$ADD_DIR_FLAGS" | awk '{for(i=1;i<=NF;i++) if($i=="--add-dir"){print $(i+1)}}'); do
stage_plan_symlink "$path" "$PLAN_SLUG" "$REPO" || \
warn "failed to stage plan symlink at $path/openspec/plans/$PLAN_SLUG"
done

# 3. Pre-spawn git cleanup (prevents 'incorrect old value provided' inside agent-branch-start.sh)
log "pruning stale remote refs"
git -C "$REPO" remote prune origin 2>&1 | sed 's/^/ /' || true
Expand Down Expand Up @@ -900,6 +943,14 @@ ticker_window claim-release "CR_SUP_SESSION=$SESSION CR_SUP_WINDOW=overview bash
# Cooldown prevents re-prompting the same worker on the same plan.
ticker_window plan-watcher "CODEX_FLEET_SESSION=$SESSION CODEX_FLEET_REPO_ROOT=$REPO bash $SCRIPT_DIR/plan-watcher.sh --loop --interval=30"

# wake-prompt: every 30s, refreshes /tmp/codex-fleet-wake-prompt.md with
# the LIVE next-available subtask from the priority plan. Without this,
# workers re-read a stale snapshot of the wake prompt that bringup captured
# at fleet start — referencing tasks that have long since been merged
# (observed 2026-05-18: every worker still pointed at TE-2 hours after
# TE-2 had landed). See scripts/codex-fleet/wake-prompt-templater.sh.
ticker_window wake-prompt "bash $SCRIPT_DIR/wake-prompt-templater.sh"

# auto-reviewer: every 5m, reviews merged PRs attached to completed plan
# sub-tasks. Slow cadence keeps Claude call cost bounded while still catching
# new completions within the hour.
Expand Down
97 changes: 97 additions & 0 deletions scripts/codex-fleet/test/run-plan-symlink.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# run-plan-symlink.sh — SI-15 smoke test for the plan-into-writable_root
# symlink staging in scripts/codex-fleet/full-bringup.sh.
#
# Extracts the stage_plan_symlink() helper from full-bringup.sh and asserts:
# (a) for a writable_root OUTSIDE the repo, the symlink is created and
# resolves to the canonical plan workspace
# (b) for a writable_root INSIDE the repo, the helper skips (no symlink
# is created — the plan already lives there)
# (c) re-running the helper is idempotent (ln -sfn replaces the link
# without error)

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FLEET_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BRINGUP="$FLEET_DIR/full-bringup.sh"

[ -f "$BRINGUP" ] || { echo "FAIL: $BRINGUP not found" >&2; exit 1; }

tmpdir="$(mktemp -d -t plan-symlink-test.XXXXXX)"
# shellcheck disable=SC2064
trap "rm -rf '$tmpdir'" EXIT

# Fake repo with one plan workspace.
repo="$tmpdir/repo"
mkdir -p "$repo/openspec/plans/test-plan-slug"
echo '{"plan_slug":"test-plan-slug"}' > "$repo/openspec/plans/test-plan-slug/plan.json"

# Foreign writable_root (outside the repo).
foreign="$tmpdir/foreign"
mkdir -p "$foreign"

# Extract stage_plan_symlink + log/warn helpers from full-bringup.sh.
helper="$tmpdir/symlink-helper.sh"
{
echo '#!/usr/bin/env bash'
echo 'set -uo pipefail'
echo 'log() { printf "[full-bringup] %s\n" "$*"; }'
echo 'warn() { printf "[full-bringup] %s\n" "$*" >&2; }'
awk '
/^stage_plan_symlink\(\) \{/ { capture=1 }
capture { print }
capture && /^\}$/ { capture=0; exit }
' "$BRINGUP"
} > "$helper"

# shellcheck disable=SC1090
. "$helper"

fail=0

# Case (a): foreign writable_root → symlink created.
stage_plan_symlink "$foreign" "test-plan-slug" "$repo" >/dev/null
link="$foreign/openspec/plans/test-plan-slug"
if [ -L "$link" ] && [ -e "$link" ]; then
resolved="$(readlink "$link")"
if [ "$resolved" = "$repo/openspec/plans/test-plan-slug" ]; then
echo "OK: foreign writable_root → symlink resolves to canonical plan dir"
else
echo "FAIL: foreign writable_root → symlink points at '$resolved'" >&2
fail=$((fail + 1))
fi
else
echo "FAIL: foreign writable_root → symlink not created at $link" >&2
fail=$((fail + 1))
fi

# Case (b): writable_root inside the repo → skipped.
inside="$repo/inside-root"
mkdir -p "$inside"
stage_plan_symlink "$inside" "test-plan-slug" "$repo" >/dev/null
inside_link="$inside/openspec/plans/test-plan-slug"
if [ -L "$inside_link" ] || [ -e "$inside_link" ]; then
echo "FAIL: in-repo writable_root → unexpected symlink/dir at $inside_link" >&2
fail=$((fail + 1))
else
echo "OK: in-repo writable_root → skipped (no symlink staged)"
fi

# Case (c): re-running on the foreign root is idempotent.
if stage_plan_symlink "$foreign" "test-plan-slug" "$repo" >/dev/null; then
if [ -L "$link" ]; then
echo "OK: re-running stage_plan_symlink is idempotent"
else
echo "FAIL: re-run lost the symlink" >&2
fail=$((fail + 1))
fi
else
echo "FAIL: re-run returned non-zero" >&2
fail=$((fail + 1))
fi

[ "$fail" -eq 0 ] || exit 1
echo "summary: plan-symlink ok"
119 changes: 119 additions & 0 deletions scripts/codex-fleet/test/run-wake-templater.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# run-wake-templater.sh — SI-19 smoke test for wake-prompt-templater.sh.
#
# Sets up a fixture repo with .codex-fleet/active-plan + a fake plan, then
# injects a mock `colony` CLI via WAKE_COLONY_BIN that returns canned JSON
# for `task ready --json`. Runs the templater with WAKE_ONCE=1 and asserts
# the rendered file contains the substituted slug + title.
#
# Also verifies the exhausted-variant render when the mock returns empty.

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FLEET_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TEMPLATER="$FLEET_DIR/wake-prompt-templater.sh"
TEMPLATE="$FLEET_DIR/wake-prompt.template.md"

[ -x "$TEMPLATER" ] || { echo "FAIL: $TEMPLATER not executable" >&2; exit 1; }
[ -r "$TEMPLATE" ] || { echo "FAIL: $TEMPLATE missing" >&2; exit 1; }

tmpdir="$(mktemp -d -t wake-templater-test.XXXXXX)"
# shellcheck disable=SC2064
trap "rm -rf '$tmpdir'" EXIT

# Fixture: fake repo with active-plan + a plan workspace.
fake_repo="$tmpdir/repo"
mkdir -p "$fake_repo/.codex-fleet" "$fake_repo/openspec/plans/fixture-plan-2026-05-18"
printf '%s' 'fixture-plan-2026-05-18' > "$fake_repo/.codex-fleet/active-plan"

# Mock colony — emits a single ready item matching the fixture plan slug.
mkdir -p "$tmpdir/bin"
cat > "$tmpdir/bin/colony" <<'MOCK'
#!/usr/bin/env bash
# Mock colony CLI: when asked for `task ready --json`, emit a canned payload.
# Honor WAKE_TEST_MODE=exhausted to emit an empty payload (no ready items).
if [ "${1:-}" = "task" ] && [ "${2:-}" = "ready" ]; then
shift 2
for arg in "$@"; do
case "$arg" in
--json) ;;
esac
done
if [ "${WAKE_TEST_MODE:-live}" = "exhausted" ]; then
cat <<'JSON'
{"ready":[]}
JSON
else
cat <<'JSON'
{"ready":[{"plan_slug":"fixture-plan-2026-05-18","subtask_index":7,"title":"[FIX-7] live templater smoke test subject","description":"Make sure the wake-prompt-templater renders the live next-subtask into /tmp/codex-fleet-wake-prompt.md."}]}
JSON
fi
exit 0
fi
exit 0
MOCK
chmod +x "$tmpdir/bin/colony"

fail=0

# ---- Live variant: live next subtask is rendered ----
output_path="$tmpdir/wake-live.md"
WAKE_ONCE=1 \
WAKE_TEMPLATE_PATH="$TEMPLATE" \
WAKE_OUTPUT_PATH="$output_path" \
WAKE_COLONY_BIN="$tmpdir/bin/colony" \
CODEX_FLEET_REPO_ROOT="$fake_repo" \
WAKE_TEST_MODE=live \
bash "$TEMPLATER" >/dev/null 2>&1

if [ ! -f "$output_path" ]; then
echo "FAIL: live variant — output file not written: $output_path" >&2
fail=$((fail + 1))
else
if grep -q 'fixture-plan-2026-05-18' "$output_path" \
&& grep -q '\[FIX-7\] live templater smoke test subject' "$output_path" \
&& grep -q '7' "$output_path"; then
echo "OK: live variant — slug + title + index substituted"
else
echo "FAIL: live variant — output missing slug/title/index" >&2
sed 's/^/ /' "$output_path" >&2
fail=$((fail + 1))
fi
# The live variant must NOT render the plan-exhausted notice.
if grep -q 'plan-exhausted' "$output_path"; then
echo "FAIL: live variant — exhausted notice should be empty" >&2
fail=$((fail + 1))
else
echo "OK: live variant — no exhausted notice"
fi
fi

# ---- Exhausted variant: no ready items → exhausted notice rendered ----
output_path2="$tmpdir/wake-exhausted.md"
WAKE_ONCE=1 \
WAKE_TEMPLATE_PATH="$TEMPLATE" \
WAKE_OUTPUT_PATH="$output_path2" \
WAKE_COLONY_BIN="$tmpdir/bin/colony" \
CODEX_FLEET_REPO_ROOT="$fake_repo" \
WAKE_TEST_MODE=exhausted \
bash "$TEMPLATER" >/dev/null 2>&1

if [ ! -f "$output_path2" ]; then
echo "FAIL: exhausted variant — output file not written" >&2
fail=$((fail + 1))
else
if grep -q 'plan-exhausted' "$output_path2" \
&& grep -q 'fixture-plan-2026-05-18' "$output_path2"; then
echo "OK: exhausted variant — plan-exhausted notice + slug rendered"
else
echo "FAIL: exhausted variant — missing exhausted notice or slug" >&2
sed 's/^/ /' "$output_path2" >&2
fail=$((fail + 1))
fi
fi

[ "$fail" -eq 0 ] || exit 1
echo "summary: wake-templater ok"
Loading