diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5695248..f7736fe 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -20,7 +20,7 @@ "url": "https://github.com/choiyounggi/dev-loop.git" }, "homepage": "https://github.com/choiyounggi/dev-loop", - "version": "1.1.0", + "version": "1.2.0", "tags": [ "orchestrator", "verification-loop", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3c3aecc..e78e5e3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dev-loop", "description": "loop-orchestrator's verification loop (TDD / PDCA / Reflexion) with the plan step FIXED to a wiki-grounded planning methodology (wiki-plan): every design decision is routed to a bundled semantic-layer wiki before code is written. Adds a knowledge-capture loop — sessions emit verified insights, and knowledge-flush researches, dedups, routes, and opens a wiki PR for owner review.", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "choiyounggi", "url": "https://github.com/choiyounggi" diff --git a/.gitignore b/.gitignore index 1fca0b3..cbb4669 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .DS_Store *.log .claude/ +.worktrees/ diff --git a/skills/orchestrate/SKILL.md b/skills/orchestrate/SKILL.md index 026968d..31c5a2d 100644 --- a/skills/orchestrate/SKILL.md +++ b/skills/orchestrate/SKILL.md @@ -13,8 +13,9 @@ implementation loop; two human gates bracket it (task-split, pre-merge). Scripts referenced below live in `${CLAUDE_PLUGIN_ROOT}/skills/orchestrate/scripts/`. Communication: session→orchestrator via `.orchestration/status/.json`; -orchestrator→session via `tmux send-keys` one-liners (templates/session-prompt.md -§1–§4) on tmux, or the Task `--spec` (same file, §O1–§O4) on Orca. +orchestrator→session via `launch-session.sh` (the first prompt) then +`send-prompt.sh` (every later one), carrying templates/session-prompt.md §1–§4 on +tmux, or the Task `--spec` (same file, §O1–§O4) on Orca. ## Tool profile Resolve the pluggable tool profile once up front: @@ -95,6 +96,16 @@ design links entirely — the original behavior. Report the task list, Waves, session count, and a rough cost note. **Wait for the user's approval** before launching anything. +**Substrate — ask here, in this same turn.** Before writing that report, run +`scripts/orca-detect.sh`. Non-zero (no Orca): tmux, silently — say nothing about +substrates. Exit 0: report that Orca was detected and put the choice in this same +report — **Orca**: native trust-screen handling, event-driven waits, native +liveness; **tmux**: mid-flight steering via `send-prompt.sh`, no extra dependency. **Wait +for the user's answer**; their answer decides, and you carry it into Phase 3. A +detected Orca always asks — there is no default, no remembered choice, no +environment override. Launch nothing until both the split and the substrate are +answered. + ## Phase 3 — Launch + plan (per Wave) **Phases 3–4 repeat per Wave in `## Waves` order.** A later Wave launches only after the previous Wave is fully approved; `` below = the *current* Wave's task count. @@ -102,20 +113,25 @@ Single-Wave splits run everyone in parallel (the original behavior). **Session knobs (tmux substrate, set once per run):** `export LO_RUN_ID=` so every `launch-session.sh` gets a collision-proof name `lo--` (reuse that -exact name for later `send-keys`); the script also exports the guardrails escalation env -into each worker. Trust-screen wording drifts between CLI releases — if a launch -hangs, set `LO_READY_EXTRA` / `LO_TRUST_EXTRA` (substrings) or `LO_READY_TIMEOUT`. +exact name for later `send-prompt.sh`); the script also exports the guardrails +escalation env into each worker. Trust-screen wording drifts between CLI releases — +if a launch hangs, set `LO_READY_EXTRA` / `LO_TRUST_EXTRA` (substrings) or +`LO_READY_TIMEOUT`. `watch-status.sh` now exits **5** on a pending guardrails escalation (approve/deny, clear `.orchestration/escalations/`, relaunch) and **3** on a failed OR a *dead* worker (a non-terminal task whose tmux session vanished — recorded via the status -file's `session` field) — both abort fast instead of waiting the timeout. +file's `session` field) — both abort fast instead of waiting the timeout. Give each +phase its own deadline with one exported +`LO_PHASE_TIMEOUTS="plan_ready=900,impl_done=3600,done=1800"`, keyed on the TARGET +phase of each wait: precedence is an explicit `[timeout-sec]` argument, then the +matching entry, then the 3600s default. A malformed entry (no `=`, non-numeric, +`<= 0`, or an unknown phase name) is refused with **exit 4**, never silently +defaulted; the effective budget and its source are printed before the wait. -**Substrate selection (automatic — do this FIRST, before any launch):** run -`scripts/orca-detect.sh`. If it exits 0 (Orca installed, its orchestration skill -present, runtime reachable), Orca is the substrate — spawn **and supervise** workers -through it, not raw tmux. This is the **default when Orca is present, not optional**: -Orca resolves the trust/TUI screen, gives native liveness, and pushes worker events -to you instead of making you poll. Replace steps 1–3 below with O1–O5: +**Substrate (the user decided at Gate 1 — do not re-decide here):** the answer was +Orca or tmux. **Orca** → spawn **and supervise** workers through it, not raw tmux: +it resolves the trust/TUI screen, gives native liveness, and pushes worker events to +you instead of making you poll. Replace steps 1–3 below with O1–O5: - **O1 — bind the Run once per orchestration run.** `orca orchestration run-create --objective "" --json` → keep `run_id` for the @@ -224,10 +240,11 @@ when the worker never sends the message (it died, or it is not a Claude session) Export that dir for both scripts and the file stays the safety net the tmux path already relied on. -If `orca-detect.sh` is NON-zero (no Orca), fall back to the tmux `launch-session.sh` -+ `watch-status.sh` path below, unchanged. Always verify each Orca `--json` result -before relying on its fields (`worker-start` returns `.result.dispatchId`, and the -agent handle as the `role:"agent"` entry in `.result.effects[]`). `orca-spawn.sh` +If the user chose tmux at Gate 1 (or `orca-detect.sh` was non-zero), use the tmux +`launch-session.sh` + `watch-status.sh` path below, unchanged. Always verify each +Orca `--json` result before relying on its fields (`worker-start` returns +`.result.dispatchId`, and the agent handle as the `role:"agent"` entry in +`.result.effects[]`). `orca-spawn.sh` remains only for a worker needing custom agent argv (e.g. codex `--model` / reasoning-effort flags) that `worker-start` cannot express. @@ -246,15 +263,32 @@ reasoning-effort flags) that `worker-start` cannot express. (Phase 2) — then `scripts/launch-session.sh lo- bypassPermissions ""` (plan prompt = templates/session-prompt.md §1 — the tmux set — with the - subagent protocol block). + subagent protocol block). Exit **0** = launched *and* the prompt confirmed + submitted; **4** = the REPL never became ready (relaunch); **5** = the prompt was + sent but submission could NOT be confirmed — the session is alive and may be + holding an unsubmitted prompt, so read it with `scripts/send-prompt.sh state + lo-` and re-send, never launch a second session on top of it. 3. `scripts/watch-status.sh plan_ready ` in the background; when it exits, collect `plans/.md`. *(Orca substrate: `scripts/orca-wait.sh ` per O4 instead — same exit-code contract, event-driven.)* *(Plans proceed autonomously per the user's choice — no per-plan gate.)* + `watch-status.sh` only answers "does the session still exist"; a worker can hold a + live session and produce nothing for hours. So on a long wait also run + `scripts/tmux-worker-stalled.sh lo-` (**0** progressing / **1** stalled / + **2** cannot tell — treat unknown as *not* stalled; silence threshold + `LO_STALL_SEC`, default 600s), the tmux mirror of O5. Read the pane before acting + on a stall: "wedged on a prompt" and "finished but never reported" look identical + from outside and need opposite responses. ## Phase 4 — Implement + review (max 3 rework) -Inject §2 (implement) to each session; `watch-status ... impl_done `. *(Orca +Deliver §2 (implement) to each session with `scripts/send-prompt.sh send lo- +""` — **0** delivered, **4** queued behind a busy turn, **7** unconfirmed, +**3** the session is gone, **2** the session name or prompt was rejected. Branch on +the exit code; stdout is exactly one token and stderr is advisory context that must +never be parsed. On **4**, `scripts/send-prompt.sh wait lo- [timeout]` blocks +until the worker picks it up (**0** picked-up, **5** deadline expired). Then +`watch-status ... impl_done `. *(Orca substrate: `task-create` the implement Task, then `scripts/orca-worker-start.sh --task --terminal ` to reuse that task's existing session, and wait with `scripts/orca-wait.sh`. Rework rounds are further Tasks on the same @@ -279,14 +313,29 @@ Show the full integration diff (`git diff`). **Wait for the user's confirmation. worktrees, merges sequentially, stops + reports on conflict (no --force). 2. `scripts/safe-cleanup.sh remove-worktrees ...` (after merge verified; skips any dirty worktree). -3. `scripts/safe-cleanup.sh kill-sessions lo-...` (exact names only). +3. `scripts/safe-cleanup.sh kill-sessions lo-...` (exact names only), or — instead + of remembering every name — `scripts/safe-cleanup.sh sweep `, the teardown + for ONE run: kill every tmux session named `lo--$LO_RUN_ID`, `git worktree + prune`, then report (never delete) any `.worktrees/` directory git does not know. + `sweep` REFUSES with exit 1, touching nothing, when `LO_RUN_ID` is unset or is not + `[A-Za-z0-9_-]+` — with no scope it would match every concurrent run's sessions. +4. `scripts/safe-cleanup.sh list-orphans ` is read-only (kills, deletes and + prunes nothing; needs no `LO_RUN_ID`) — the census across ALL run ids, and the way + to read a dead run's id before sweeping it deliberately. +`--dry-run` may appear in any argument position on any destructive verb: it prints +exactly what the real run would touch and changes nothing, while refusals (dirty +worktree) still fire — a dry run never looks safer than the real one. **Local merge into the feature branch only.** Remote push / PR is the user's job. ## Re-entry (resume) On re-invocation with no context, measure real state first: `git worktree list`, each `.orchestration/status/*.json` phase, and which `briefs/plans/reviews/` artifacts exist. Resume from the earliest incomplete step (idempotently skip done -steps). Check `tmux ls`; relaunch dead sessions and re-inject the right prompt. +steps). Check `tmux ls`, and run `scripts/tmux-worker-stalled.sh ` on each +live one — a session that exists is not a worker that moves. Relaunch dead sessions and +re-deliver the right prompt with `scripts/send-prompt.sh send`. For leftovers of a +run that already died, `scripts/safe-cleanup.sh list-orphans ` enumerates them +read-only, including each session's run id. On the Orca substrate, rebind the Run first (`orca orchestration run-use --id --json`), then measure with `orca orchestration task-list --json` + `scripts/orca-worktree-alive.sh ` **and** `scripts/orca-worker-stalled.sh ` diff --git a/skills/orchestrate/scripts/launch-session.sh b/skills/orchestrate/scripts/launch-session.sh index ea0bac5..8e5ad91 100755 --- a/skills/orchestrate/scripts/launch-session.sh +++ b/skills/orchestrate/scripts/launch-session.sh @@ -12,8 +12,43 @@ # permission screen that swallows a start-arg prompt. So we pass the screen, # confirm the REPL is ready, then send-keys the prompt separately. The screen # wording is version-dependent (design §8.11) — patterns are kept in one place. +# +# Submission pitfall: `send-keys` exiting 0 means "the keys reached the pane", +# NOT "the worker received the prompt". Measured 2026-08-05: of three sessions +# launched seconds apart, one had its prompt sit in the CLI's input box as +# "[Pasted text #1 +1 lines]" for 45 minutes while this script printed "ok"; a +# single extra Enter then ran it. So the prompt is not reported as delivered +# until the input box is observed empty again — see "confirm submission" below. +# Delivering a SUBSEQUENT prompt to an already-running session is not this +# script's job: scripts/session-send.sh owns that contract. +# +# exit 0 launched and the prompt is confirmed submitted +# exit 1 wrong argument count +# exit 2 invalid permission mode +# exit 4 the REPL never became ready (prints the last screen) +# exit 5 the prompt was sent but submission could NOT be confirmed — the +# session is alive and may hold an unsubmitted prompt (prints the +# last screen). Distinct from 4: there the CLI never came up. +# exit 127 tmux or claude not found +# +# env (also test hooks): +# LO_RUN_ID suffix making the session name unique per run +# LO_DRY_RUN print the resolved session name and exit +# LO_TMUX tmux binary (default: the one on PATH) +# LO_CLAUDE claude binary (default: the search below) +# LO_READY_TIMEOUT / LO_READY_INTERVAL REPL-ready budget (default 60 / 2); +# attempts = floor(timeout/interval), minimum 1 +# LO_READY_EXTRA / LO_TRUST_EXTRA extra screen-match substrings +# LO_SUBMIT_TIMEOUT / LO_SUBMIT_INTERVAL submission-confirm budget (default +# 20 / 2); attempts = floor(timeout/interval), minimum 1 set -eu -TMUX=$(command -v tmux) || { echo "launch-session: tmux not found" >&2; exit 127; } +# NOT named TMUX: that is tmux's OWN variable (the server socket). Assigning it +# here would overwrite the caller's exported value, and every tmux child would +# then try to connect to a binary path as a socket — measured as +# "error connecting to (Socket operation on non-socket)", which breaks +# every launch whenever the coordinator itself runs inside tmux. +TMUX_BIN="${LO_TMUX:-$(command -v tmux 2>/dev/null || true)}" +[ -n "$TMUX_BIN" ] || { echo "launch-session: tmux not found" >&2; exit 127; } [ $# -eq 4 ] || { echo "usage: launch-session.sh " >&2; exit 1; } session="$1"; wt="$2"; perm="$3"; prompt="$4" @@ -35,15 +70,17 @@ esac if [ -n "${LO_DRY_RUN:-}" ]; then echo "session=$session"; exit 0; fi # locate the claude binary (avoid nvm lazy wrappers / shell functions) -CLAUDE="" -for c in "$HOME"/.nvm/versions/node/*/bin/claude /opt/homebrew/bin/claude /usr/local/bin/claude "$HOME"/.local/bin/claude; do - [ -x "$c" ] && { CLAUDE="$c"; break; } -done -[ -n "$CLAUDE" ] || CLAUDE=$(command -v claude 2>/dev/null || true) +CLAUDE="${LO_CLAUDE:-}" +if [ -z "$CLAUDE" ]; then + for c in "$HOME"/.nvm/versions/node/*/bin/claude /opt/homebrew/bin/claude /usr/local/bin/claude "$HOME"/.local/bin/claude; do + [ -x "$c" ] && { CLAUDE="$c"; break; } + done + [ -n "$CLAUDE" ] || CLAUDE=$(command -v claude 2>/dev/null || true) +fi [ -x "$CLAUDE" ] || { echo "launch-session: claude CLI not found" >&2; exit 127; } claudedir=$(dirname "$CLAUDE") # also where node lives for nvm installs -if "$TMUX" has-session -t "$session" 2>/dev/null; then +if "$TMUX_BIN" has-session -t "$session" 2>/dev/null; then # With a unique LO_RUN_ID name this should not happen across runs; surface it # loudly (stderr) instead of a silent skip so a real collision is visible. echo "launch-session: session '$session' already exists — reusing it (set LO_RUN_ID for a unique per-run name)" >&2 @@ -51,7 +88,7 @@ if "$TMUX" has-session -t "$session" 2>/dev/null; then exit 0 fi -"$TMUX" new-session -d -s "$session" -x 220 -y 50 -c "$wt" +"$TMUX_BIN" new-session -d -s "$session" -x 220 -y 50 -c "$wt" # Route guardrails `ask` decisions in this (headless) worker to the coordinator # instead of blocking it. The escalation dir is the main repo's; task id = session. @@ -63,7 +100,7 @@ if [ -n "$esc" ]; then launchcmd="$launchcmd && export GROUNDWORK_ESCALATION_DIR='$esc' && export GROUNDWORK_TASK_ID='$session'" fi launchcmd="$launchcmd && \"$CLAUDE\" --permission-mode $perm" -"$TMUX" send-keys -t "$session" "$launchcmd" Enter +"$TMUX_BIN" send-keys -t "$session" "$launchcmd" Enter # Pass trust screen + permission warning, wait for REPL ready (~60s). # NOTE: the bypassPermissions warning defaults to "1. No, exit" — pressing Enter @@ -82,7 +119,7 @@ iters=$(( ready_timeout / ready_interval )); [ "$iters" -ge 1 ] || iters=1 ready=0; i=0; pane="" while [ "$i" -lt "$iters" ]; do sleep "$ready_interval" - pane=$("$TMUX" capture-pane -t "$session" -p 2>/dev/null || echo "") + pane=$("$TMUX_BIN" capture-pane -t "$session" -p 2>/dev/null || echo "") if [ -n "${LO_READY_EXTRA:-}" ] && printf '%s' "$pane" | grep -qF "$LO_READY_EXTRA"; then ready=1; break fi @@ -90,12 +127,12 @@ while [ "$i" -lt "$iters" ]; do *"bypass permissions on"*|*"shift+tab to cycle"*|*"for shortcuts"*|*"? for"*) ready=1; break ;; *"Yes, I accept"*|*"accept all responsibility"*) - "$TMUX" send-keys -t "$session" Down; sleep 1; "$TMUX" send-keys -t "$session" Enter ;; + "$TMUX_BIN" send-keys -t "$session" Down; sleep 1; "$TMUX_BIN" send-keys -t "$session" Enter ;; *"Do you trust"*|*"trust the files"*|*"Enter to confirm"*) - "$TMUX" send-keys -t "$session" Enter ;; + "$TMUX_BIN" send-keys -t "$session" Enter ;; *) if [ -n "${LO_TRUST_EXTRA:-}" ] && printf '%s' "$pane" | grep -qF "$LO_TRUST_EXTRA"; then - "$TMUX" send-keys -t "$session" Enter + "$TMUX_BIN" send-keys -t "$session" Enter fi ;; esac i=$((i+1)) @@ -108,6 +145,83 @@ if [ "$ready" -ne 1 ]; then fi # inject the one-line prompt (literal) then submit -"$TMUX" send-keys -t "$session" -l "$prompt" -"$TMUX" send-keys -t "$session" Enter -echo "ok: $session launched + prompt injected" +"$TMUX_BIN" send-keys -t "$session" -l "$prompt" +"$TMUX_BIN" send-keys -t "$session" Enter + +# ---- confirm submission ----------------------------------------------------- +# Both observed failure renderings leave the text sitting in the CLI's input box +# — once as the literal prompt, once collapsed to "[Pasted text #1 +1 lines]". +# So the check is on the BOX, not on the prompt: after a real submission the box +# is empty again. That is deliberately independent of the prompt's own text, +# which is what lets it catch the collapsed rendering (where the prompt is not +# on screen at all). The cause of the intermittency is not established, so this +# targets the symptom only. +# +# The box is the region between the last two horizontal rules of the pane. If +# the CLI's chrome changes shape and it cannot be located, fall back to looking +# for the prompt's own head in the bottom of the screen. Either way, "cannot +# tell" is reported as exit 5, never as success. +submit_timeout="${LO_SUBMIT_TIMEOUT:-20}"; submit_interval="${LO_SUBMIT_INTERVAL:-2}" +[ "$submit_timeout" -ge 1 ] 2>/dev/null || submit_timeout=20 +[ "$submit_interval" -ge 1 ] 2>/dev/null || submit_interval=2 +sub_iters=$(( submit_timeout / submit_interval )); [ "$sub_iters" -ge 1 ] || sub_iters=1 + +# first 40 chars of the prompt — the fallback fingerprint. 40 cannot wrap: the +# pane is created 220 columns wide above. +fingerprint=$(printf '%s' "$prompt" | cut -c1-40) + +input_box() { # $1 = pane text -> the input-box region, or rc 1 if not locatable + _rules=$(printf '%s\n' "$1" | grep -n '^[[:space:]]*─\{3,\}[[:space:]]*$' | cut -d: -f1) + [ "$(printf '%s' "$_rules" | grep -c .)" -ge 2 ] || return 1 + _last=$(printf '%s\n' "$_rules" | tail -1) + _prev=$(printf '%s\n' "$_rules" | tail -2 | head -1) + [ "$_prev" -lt "$_last" ] || return 1 + printf '%s\n' "$1" | sed -n "$((_prev + 1)),$((_last - 1))p" +} + +submitted=0; sent_extra=0; j=0 +while [ "$j" -lt "$sub_iters" ]; do + sleep "$submit_interval" + pane=$("$TMUX_BIN" capture-pane -t "$session" -p 2>/dev/null || echo "") + if [ -z "$pane" ]; then + # nothing to read (pane gone, capture failed) — cannot confirm, never assume + j=$((j + 1)); continue + fi + if box=$(input_box "$pane"); then + # empty box = the prompt left the input line = submitted. Strip the prompt + # marker itself; anything else non-blank means it is still sitting there. + if printf '%s' "$box" | sed 's/❯//g' | grep -q '[^[:space:]]'; then + still=1 + else + still=0 + fi + else + # chrome not recognised: fall back to the prompt's own head near the bottom + tail_pane=$(printf '%s\n' "$pane" | tail -15) + if [ -n "$fingerprint" ] && printf '%s' "$tail_pane" | grep -qF "$fingerprint"; then + still=1 + elif printf '%s' "$tail_pane" | grep -qF '[Pasted text'; then + still=1 + else + still=0 + fi + fi + [ "$still" -eq 0 ] && { submitted=1; break; } + # The one remedy proven in the field: a further Enter submits it. Harmless when + # the box is already empty, so it is safe to repeat within the budget. + "$TMUX_BIN" send-keys -t "$session" Enter + sent_extra=$((sent_extra + 1)) + j=$((j + 1)) +done + +if [ "$submitted" -ne 1 ]; then + echo "launch-session: $session prompt NOT confirmed submitted after ${submit_timeout}s — the session is alive and may be holding the prompt unsubmitted in its input box; send one Enter to '$session' or relaunch (last screen below)" >&2 + echo "$pane" | tail -8 >&2 + exit 5 +fi + +if [ "$sent_extra" -gt 0 ]; then + echo "ok: $session launched + prompt submitted (confirmed after $sent_extra extra Enter)" +else + echo "ok: $session launched + prompt submitted (confirmed)" +fi diff --git a/skills/orchestrate/scripts/safe-cleanup.sh b/skills/orchestrate/scripts/safe-cleanup.sh index 6e252c1..3f8cae8 100755 --- a/skills/orchestrate/scripts/safe-cleanup.sh +++ b/skills/orchestrate/scripts/safe-cleanup.sh @@ -7,17 +7,44 @@ # Verify it is safe to `git init` : refuse if it sits inside an # existing repo (nesting) or contains secret-like files; warn if no # .gitignore. (design §6) -# safe-cleanup.sh merge ... +# safe-cleanup.sh merge [--dry-run] ... # Refuse if any worktree has uncommitted changes, then merge each task # branch into the integration branch sequentially; stop on conflict and # report merged/remaining. (design §7.2/§7.3) -# safe-cleanup.sh remove-worktrees ... +# safe-cleanup.sh remove-worktrees [--dry-run] ... # Remove each task worktree, refusing any with uncommitted changes -# (never --force). (design §7.4/§8.7) -# safe-cleanup.sh kill-sessions ... +# (never --force), then `git worktree prune`. (design §7.4/§8.7) +# safe-cleanup.sh kill-sessions [--dry-run] ... # Kill ONLY the exact session names given (no prefix/grep). (design §7.5/§8.9) +# safe-cleanup.sh sweep [--dry-run] [requires LO_RUN_ID] +# Teardown for ONE run: kill every tmux session named lo--$LO_RUN_ID, +# then `git worktree prune`, then report (never delete) any .worktrees/ +# directory git does not know about. The coordinator no longer has to +# remember each session name. +# safe-cleanup.sh list-orphans [read-only] +# Enumerate orphan candidates across ALL run ids and exit. Kills nothing, +# deletes nothing, prunes nothing. Needs no LO_RUN_ID. +# +# --dry-run may appear in any argument position on any destructive verb. It +# prints exactly what the real run would touch and changes nothing; refusals +# (dirty worktree, etc.) still fire, so a dry run never looks safer than the +# real one. +# +# LO_RUN_ID scoping (sweep only): +# `sweep` REFUSES (exit 1, nothing touched) when LO_RUN_ID is unset or is not +# [A-Za-z0-9_-]+ . With no scope the pattern would match every concurrent +# run's sessions, so it fails closed rather than widening. Two coordinators on +# one machine therefore cannot tear down each other's workers. +# To clean up a run that already died: read its id from `list-orphans`, then +# re-run with LO_RUN_ID= sweep — deliberate, never automatic. +# +# list-orphans record format (tab-separated, one record per line): +# sessionrun=created= +# worktree-stalereason=gitdir-missing +# worktree-unregistered set -eu GIT=$(command -v git) || { echo "safe-cleanup: git not found" >&2; exit 127; } +DRY=0 # set by --dry-run in the dispatcher below; read by every destructive verb init_check() { wd="${1:?init-check: workdir required}" @@ -51,6 +78,10 @@ merge() { fi done # 2) sequential merge; stop on conflict, record state (design §7.3) + if [ "$DRY" -eq 1 ]; then + for br in "$@"; do echo "would merge: $br -> $integ"; done + return 0 + fi "$GIT" -C "$root" checkout "$integ" >/dev/null 2>&1 || { echo "merge: cannot checkout $integ" >&2; return 1; } merged="" for br in "$@"; do @@ -76,22 +107,35 @@ remove_worktrees() { echo "remove-worktrees: SKIP — uncommitted changes in $wt (never --force)." >&2 continue fi + if [ "$DRY" -eq 1 ]; then echo "would remove: $wt"; continue; fi if "$GIT" -C "$root" worktree remove "$wt"; then echo "removed: $wt" else echo "remove-worktrees: FAILED to remove $wt (locked/prunable?) — left in place" >&2 fi done + # removals (and worktrees whose directory vanished on their own) leave git + # admin files behind — clear them here rather than letting them accumulate + prune_worktrees "$root" return 0 } kill_sessions() { - TMUX=$(command -v tmux) || { echo "kill-sessions: tmux not found" >&2; return 127; } + # NEVER name this variable TMUX: tmux exports $TMUX (its server socket) into + # every session, so assigning to it here inherits the export attribute and + # every subsequent tmux call tries to use the binary path as its socket + # ("Socket operation on non-socket"). The orchestrator runs this script from + # inside a tmux session, so that silently turned teardown into a no-op. + TMUXBIN=$(command -v tmux) || { echo "kill-sessions: tmux not found" >&2; return 127; } [ $# -ge 1 ] || { echo "kill-sessions: need session name(s)" >&2; return 1; } for s in "$@"; do # exact target only — has-session -t matches the exact name, never a prefix/grep - if "$TMUX" has-session -t "$s" 2>/dev/null; then - "$TMUX" kill-session -t "$s" && echo "killed: $s" + if "$TMUXBIN" has-session -t "$s" 2>/dev/null; then + if [ "$DRY" -eq 1 ]; then + echo "would kill: $s" + else + "$TMUXBIN" kill-session -t "$s" && echo "killed: $s" + fi else echo "skip: $s (no such session)" fi @@ -99,11 +143,158 @@ kill_sessions() { return 0 } +# Drop git's administrative files for worktrees whose directory is already gone. +# This is the only "worktree removal" a sweep performs: a registered, live +# worktree is never removed here — that stays an explicit `remove-worktrees`. +prune_worktrees() { + root="${1:?prune: repo-root required}" + # NOTE: `git worktree prune --verbose` reports on STDERR, not stdout, so the + # capture must redirect 2>&1 or the report is lost and this looks like a no-op. + if [ "$DRY" -eq 1 ]; then + pout=$("$GIT" -C "$root" worktree prune --dry-run --verbose 2>&1) + else + pout=$("$GIT" -C "$root" worktree prune --verbose 2>&1) + fi + if [ -n "$pout" ]; then + printf '%s\n' "$pout" | while IFS= read -r pl; do echo "prune: $pl"; done + fi + return 0 +} + +# Report — never delete — a .worktrees/ directory git does not know about. It +# may hold uncommitted work, so widening the sweep to delete it would make +# teardown more dangerous than the explicit verb it replaces. +report_unregistered() { + root="${1:?report-unregistered: repo-root required}" + [ -d "$root/.worktrees" ] || return 0 + reg=$("$GIT" -C "$root" worktree list --porcelain 2>/dev/null | awk '/^worktree /{ sub(/^worktree /, ""); print }') + for d in "$root"/.worktrees/*; do + [ -d "$d" ] || continue # no matches: the glob stayed literal + # compare physical paths — git reports realpath, the glob may carry a symlink + dphys=$(cd "$d" 2>/dev/null && pwd -P) || continue + printf '%s\n' "$reg" | grep -qxF "$dphys" && continue + echo "sweep: unregistered worktree dir (not removed — may hold work): $d" + done + return 0 +} + +# Validate the run scope BEFORE any destructive sweep. LO_RUN_ID is interpolated +# into tmux -t targets and into a glob, so it is checked against a strict +# allowlist and REFUSED when absent: with no scope the pattern would match every +# concurrent run's sessions, so this fails closed rather than widening. +run_scope() { + rid="${LO_RUN_ID:-}" + case "$rid" in + ''|*[!A-Za-z0-9_-]*) + echo "sweep: REFUSE — LO_RUN_ID unset or malformed (need [A-Za-z0-9_-]+); refusing to sweep an unscoped pattern." >&2 + return 1 ;; + esac + return 0 +} + +sweep() { + root="${1:?sweep: repo-root required}" + run_scope || return 1 + TMUXBIN=$(command -v tmux) || { echo "sweep: tmux not found" >&2; return 127; } + # Capture the roster BEFORE looping: `cmd | while` runs its body in a subshell + # in POSIX sh, so the counter below would not survive the pipeline. + roster=$("$TMUXBIN" list-sessions -F '#{session_name}' 2>/dev/null || true) + cnt=0 + # Session names are [A-Za-z0-9_-] by construction, so default IFS splitting is safe. + for s in $roster; do + # whole-name match against this run only — never a bare lo-* prefix sweep + case "$s" in + lo-*-"$rid") : ;; + *) continue ;; + esac + # ...and the segment between `lo-` and the run id must be a single field, so + # a foreign run whose id merely ENDS with ours (lo-1-x-runA) cannot alias in. + mid=${s#lo-}; mid=${mid%-"$rid"} + case "$mid" in ''|*-*) continue ;; esac + if [ "$DRY" -eq 1 ]; then + echo "would kill: $s" + else + "$TMUXBIN" kill-session -t "$s" && echo "killed: $s" + fi + cnt=$((cnt + 1)) + done + if [ "$DRY" -eq 1 ]; then + echo "sweep: $cnt session(s) would be killed for run $rid" + else + echo "sweep: $cnt session(s) killed for run $rid" + fi + prune_worktrees "$root" + report_unregistered "$root" + return 0 +} + +# Read-only enumeration of orphan candidates. Deliberately NOT scoped to +# LO_RUN_ID: a run that died mid-flight took its id with it, so scoping the +# listing would hide exactly the leaks worth finding. Removal stays scoped +# (`sweep`), so discovery is cheap and destruction stays deliberate. +list_orphans() { + root="${1:?list-orphans: repo-root required}" + TMUXBIN=$(command -v tmux 2>/dev/null) || TMUXBIN="" + if [ -n "$TMUXBIN" ]; then + roster=$("$TMUXBIN" list-sessions -F '#{session_name} #{session_created}' 2>/dev/null || true) + printf '%s\n' "$roster" | while IFS= read -r line; do + [ -n "$line" ] || continue + name=${line%% *} + created=${line#* } + [ "$created" = "$line" ] && created=unknown + # trailing field after the last '-' is the run id; a name without one + # (e.g. a hand-made `lo-test`) is reported as run=none, never dropped + case "$name" in + lo-*-*) rid=${name##*-} ;; + *) rid=none ;; + esac + printf 'session\t%s\trun=%s\tcreated=%s\n' "$name" "$rid" "$created" + done + else + # enumeration is informational — a missing tmux must not fail the command + echo "list-orphans: tmux not found — session enumeration skipped" >&2 + fi + + reg=$("$GIT" -C "$root" worktree list --porcelain 2>/dev/null | awk '/^worktree /{ sub(/^worktree /, ""); print }') || reg="" + printf '%s\n' "$reg" | while IFS= read -r w; do + [ -n "$w" ] || continue + [ -d "$w" ] || printf 'worktree-stale\t%s\treason=gitdir-missing\n' "$w" + done + + if [ -d "$root/.worktrees" ]; then + for d in "$root"/.worktrees/*; do + [ -d "$d" ] || continue + dphys=$(cd "$d" 2>/dev/null && pwd -P) || continue + printf '%s\n' "$reg" | grep -qxF "$dphys" && continue + printf 'worktree-unregistered\t%s\n' "$d" + done + fi + return 0 +} + cmd="${1:-}"; [ $# -ge 1 ] && shift || true + +# Strip --dry-run from ANY position, then hand the remaining operands to the +# verb. This rotates each argument through "$@", which preserves order and +# arguments containing spaces without arrays (POSIX sh has none) and without +# eval. It must stay INLINE: `set --` inside a function rebinds only that +# function's positional parameters, so a helper would discard the rebuilt list. +n=$# +while [ "$n" -gt 0 ]; do + a="$1"; shift + case "$a" in + --dry-run) DRY=1 ;; + *) set -- "$@" "$a" ;; + esac + n=$((n - 1)) +done + case "$cmd" in init-check) init_check "$@" ;; merge) merge "$@" ;; remove-worktrees) remove_worktrees "$@" ;; kill-sessions) kill_sessions "$@" ;; - *) echo "usage: safe-cleanup.sh {init-check |merge ...|remove-worktrees ...|kill-sessions ...}" >&2; exit 1 ;; + sweep) sweep "$@" ;; + list-orphans) list_orphans "$@" ;; + *) echo "usage: safe-cleanup.sh {init-check |merge [--dry-run] ...|remove-worktrees [--dry-run] ...|kill-sessions [--dry-run] ...|sweep [--dry-run] |list-orphans }" >&2; exit 1 ;; esac diff --git a/skills/orchestrate/scripts/send-prompt.sh b/skills/orchestrate/scripts/send-prompt.sh new file mode 100755 index 0000000..a67742e --- /dev/null +++ b/skills/orchestrate/scripts/send-prompt.sh @@ -0,0 +1,243 @@ +#!/bin/sh +# send-prompt.sh — deliver a prompt to an ALREADY-RUNNING worker session, and +# report what actually happened. +# +# `tmux send-keys` succeeds as soon as the keys are written to the pane. That +# says nothing about whether the worker received the prompt: measured on this +# repo, a second prompt sent while a worker was mid-turn sat as +# "Press up to edit queued messages" for 55s while a Stop-hook chain drained, +# and tmux reported success immediately. This script turns that into a verdict. +# +# The launch path is NOT here — launch-session.sh owns the trust screen and the +# first injection. This script is for every prompt after that one. +# +# usage: +# send-prompt.sh send deliver one prompt, classify it +# send-prompt.sh wait [timeout] block until the worker picks it up +# send-prompt.sh state read-only: print one state token +# +# exit send wait state +# 0 delivered picked-up ready +# 1 usage error usage error usage error +# 2 invalid input (session name / prompt rejected) — injection guard +# 3 session gone session gone gone +# 4 queued — busy +# 5 — deadline expired — +# 6 tmux command failed against a live session +# 7 unconfirmed — — +# 127 tmux not found +# +# stdout is exactly one token — delivered|queued|unconfirmed|picked-up|timeout| +# ready|busy|gone. Branch on the exit code; stderr is advisory context only and +# must never be parsed. +# +# env: +# LO_QUEUED_PATTERN substring meaning "queued behind a busy turn" +# LO_BUSY_PATTERN substring meaning "mid-turn" (state only) +# LO_PANE_TAIL_LINES non-empty pane lines searched for those (default 6) +# LO_CONFIRM_DELAY seconds to settle before classifying a send (default 1) +# LO_PICKUP_TIMEOUT wait deadline in seconds (default 180) +# LO_PICKUP_INTERVAL wait poll interval in seconds (default 2) +set -eu + +# NOT named TMUX: tmux exports TMUX=",," into every pane, +# so a script running inside a session inherits it as an EXPORTED variable. +# Assigning to it keeps it exported, and the tmux we then invoke reads the +# binary path as a socket path and dies with "Socket operation on non-socket". +TMUX_BIN=$(command -v tmux) || { echo "send-prompt: tmux not found" >&2; exit 127; } + +# The queued/busy wording belongs to the Claude Code CLI, not to this repo, so a +# CLI release can rename it. Observed 2026-08-05. Override rather than guess: +# a stale pattern must fail loudly here, not silently downgrade a queued prompt +# into a reported "delivered". +queued_pat="${LO_QUEUED_PATTERN:-Press up to edit queued messages}" +busy_pat="${LO_BUSY_PATTERN:-esc to interrupt}" + +# Every grep against these uses `-- "$pat"`. A pattern beginning with '-' is +# otherwise parsed as a grep flag ("unrecognized option"), which is the same +# defect the '--' before the send-keys payload guards against. + +# Seconds to let the pane settle before classifying a send. Sanitised, because a +# junk value would otherwise reach `sleep` and abort the script under `set -e` +# with an exit code that means nothing to the caller. +confirm_delay="${LO_CONFIRM_DELAY:-1}" +case "$confirm_delay" in ''|*[!0-9]*) confirm_delay=1 ;; esac + +usage() { + cat >&2 <<'EOF' +usage: send-prompt.sh ... + send deliver one prompt to a running session + wait [timeout] block until the worker picks the prompt up + state print one state token (read-only) +EOF + exit 1 +} + +# A tmux target without a leading '=' is PREFIX-matched: `-t lo-1` resolves to a +# session actually named `lo-1-r1`, so every target here is exact. +# The two target kinds are NOT interchangeable: has-session takes a session +# ("=name"), while capture-pane and send-keys take a PANE ("=name:" — exact +# session, its current window, active pane). "=name" alone fails a pane target +# with "can't find pane". +target_session() { printf '=%s' "$1"; } +target_pane() { printf '=%s:' "$1"; } + +# Allowlist the session name. It is interpolated into a tmux command line, where +# a leading '-' would be parsed as a flag, so reject anything that is not a +# plain identifier instead of trying to escape it. +validate_session() { + case "$1" in + ''|-*|*[!A-Za-z0-9_-]*) + echo "send-prompt: invalid session name '$1' (allowed: A-Z a-z 0-9 _ -, not leading '-')" >&2 + exit 2 ;; + esac +} + +# The prompt is free text (it carries a whole task brief), so it gets no +# allowlist — only an encoding check. Control characters are rejected: a newline +# would submit a partial prompt and leave the remainder as a stray command. +# LC_ALL=C keeps this to bytes 0x00-0x1F/0x7F, so UTF-8 text survives intact. +validate_prompt() { + [ -n "$1" ] || { echo "send-prompt: empty prompt" >&2; exit 2; } + stripped=$(printf '%s' "$1" | LC_ALL=C tr -d '[:cntrl:]') + [ "$stripped" = "$1" ] || { + echo "send-prompt: prompt contains a control character" >&2; exit 2; } +} + +session_alive() { "$TMUX_BIN" has-session -t "$(target_session "$1")" 2>/dev/null; } + +# The last non-empty lines of the pane. A TUI paints its queued/busy indicator +# at the bottom and repaints it away when the queue drains; scrollback further +# up never clears, so searching the whole pane would latch on stale text. +# +# Returns non-zero when the capture itself fails. That must NOT collapse into an +# empty string: "no marker found" and "could not look" are different answers, +# and silently returning the former is how a stale verdict gets reported as fact. +pane_tail() { + raw=$(capture_pane "$1") || return 1 + printf '%s\n' "$raw" | grep -v '^[[:space:]]*$' \ + | tail -n "${LO_PANE_TAIL_LINES:-6}" || true +} + +capture_pane() { "$TMUX_BIN" capture-pane -t "$(target_pane "$1")" -p 2>/dev/null; } + +# Advisory context for a human reading the log — never part of stdout. +note_pane() { + last=$(pane_tail "$1" | tail -n 1) + [ -n "$last" ] && echo "send-prompt[$1]: $last" >&2 || true +} + +cmd_state() { + [ $# -eq 1 ] || usage + validate_session "$1" + if ! session_alive "$1"; then + echo "gone"; exit 3 + fi + pane=$(pane_tail "$1") || { + echo "send-prompt: capture-pane failed for live session '$1'" >&2; exit 6; } + if printf '%s' "$pane" | grep -qF -- "$queued_pat" 2>/dev/null \ + || printf '%s' "$pane" | grep -qF -- "$busy_pat" 2>/dev/null; then + note_pane "$1" + echo "busy"; exit 4 + fi + echo "ready"; exit 0 +} + +cmd_send() { + [ $# -eq 2 ] || usage + validate_session "$1" + validate_prompt "$2" + if ! session_alive "$1"; then + echo "gone"; exit 3 + fi + + before=$(pane_tail "$1") || { + echo "send-prompt: capture-pane failed for live session '$1'" >&2; exit 6; } + + # The payload goes after '--'. Without it tmux parses a prompt that begins with + # '-' as its own flag ("unknown flag -n") and the prompt is never delivered. + # The prompt never reaches a shell: it is one argv element to tmux, so shell + # metacharacters in it are inert. + failed=0 + "$TMUX_BIN" send-keys -t "$(target_pane "$1")" -l -- "$2" || failed=1 + [ "$failed" -eq 1 ] || "$TMUX_BIN" send-keys -t "$(target_pane "$1")" Enter || failed=1 + if [ "$failed" -eq 1 ]; then + # A send can fail because the worker died mid-call. Re-ask rather than + # reporting a generic error for what is really a gone session. + session_alive "$1" || { echo "gone"; exit 3; } + echo "send-prompt: send-keys failed for live session '$1'" >&2 + exit 6 + fi + + sleep "$confirm_delay" + after=$(pane_tail "$1") || { + echo "send-prompt: capture-pane failed for live session '$1'" >&2; exit 6; } + + # Order is load-bearing, do not reorder. A busy pane still ECHOES the typed + # characters, so the pane changes even when the prompt was only queued — + # measured. Testing "did the pane change" first therefore reports a false + # "delivered" for exactly the case this script exists to catch. + if printf '%s' "$after" | grep -qF -- "$queued_pat" 2>/dev/null; then + note_pane "$1" + echo "queued"; exit 4 + fi + if [ "$after" != "$before" ]; then + echo "delivered"; exit 0 + fi + # Keys were written, but nothing observable happened. Report the uncertainty + # instead of defaulting to success. + note_pane "$1" + echo "unconfirmed"; exit 7 +} + +# Bounded wait for the worker to actually pick a queued prompt up. +# +# The deadline is counted in poll iterations, not clock arithmetic: `timeout(1)` +# is absent from stock macOS and `date -d` is GNU-only, so either would work on +# CI's ubuntu runner and fail on its macos one. +# +# Default 180s: the field case took 55s to pick up, behind a Stop-hook chain +# that ran 116s. 180s is that worst observation plus headroom, not a guess. +cmd_wait() { + [ $# -ge 1 ] && [ $# -le 2 ] || usage + validate_session "$1" + + timeout="${2:-${LO_PICKUP_TIMEOUT:-180}}" + case "$timeout" in ''|*[!0-9]*) usage ;; esac + [ "$timeout" -ge 1 ] || usage + + interval="${LO_PICKUP_INTERVAL:-2}" + case "$interval" in ''|*[!0-9]*) interval=2 ;; esac + [ "$interval" -ge 1 ] || interval=2 + + # Floor UP to one iteration: a deadline shorter than a single interval must + # still look once, or an idle worker is reported as a false timeout. + iters=$(( timeout / interval )) + [ "$iters" -ge 1 ] || iters=1 + + i=0 + while [ "$i" -lt "$iters" ]; do + session_alive "$1" || { echo "gone"; exit 3; } + pane=$(pane_tail "$1") || { + echo "send-prompt: capture-pane failed for live session '$1'" >&2; exit 6; } + if ! printf '%s' "$pane" | grep -qF -- "$queued_pat" 2>/dev/null; then + echo "picked-up"; exit 0 + fi + sleep "$interval" + i=$((i+1)) + done + + # Still queued at the deadline. This is NOT "lost" — it is "still working", + # and it gets its own code so a coordinator can tell the two apart. + note_pane "$1" + echo "timeout"; exit 5 +} + +[ $# -ge 1 ] || usage +sub="$1"; shift +case "$sub" in + send) cmd_send "$@" ;; + wait) cmd_wait "$@" ;; + state) cmd_state "$@" ;; + *) usage ;; +esac diff --git a/skills/orchestrate/scripts/tmux-worker-stalled.sh b/skills/orchestrate/scripts/tmux-worker-stalled.sh new file mode 100644 index 0000000..da55c8d --- /dev/null +++ b/skills/orchestrate/scripts/tmux-worker-stalled.sh @@ -0,0 +1,127 @@ +#!/bin/sh +# tmux-worker-stalled.sh — is an ALIVE tmux worker actually moving? +# +# watch-status.sh answers "does the tmux session still exist", which is not the +# same question. A worker can hold a live session and produce nothing for hours — +# a Claude CLI sitting on an interactive prompt (a usage-limit chooser, a trust +# screen, an auth re-login, or a prompt that was typed but never submitted) is +# alive, `tmux has-session` succeeds, and watch-status.sh just keeps polling. +# Measured on the Orca side: three workers held that state for 75 minutes with +# byte-identical diffs. This script supplies the missing third state for tmux, +# mirroring orca-worker-stalled.sh. +# +# Signal: the hash of `capture-pane -p`. A pane whose rendered content is +# unchanged for longer than the threshold, while its session is still alive, is +# a stall. tmux exposes no "content last changed" timestamp, so the silence is +# accumulated across polls through a small state file (see below) rather than +# measured in one call — the caller is expected to poll (watch-status.sh's own +# cadence is 15s). +# +# NOT `#{window_activity}` — measured 2026-08-05 and rejected. It advances on any +# byte written to the pane, so a TUI that merely repaints keeps it fresh: a pane +# rewriting the SAME bytes every 0.5s and a pane doing real work both reported it +# tracking `now` exactly. That is the same trap Orca hit with `lastOutputAt`. +# `#{session_activity}` was rejected too — it never advanced for any pane. +# +# exit 0 progressing — the pane changed within the threshold, this is the +# first observation, or the session is gone (that is the alive/dead +# question, and watch-status.sh already owns it) +# exit 1 STALLED — a live session whose pane has not changed for >= threshold +# exit 2 cannot tell (no tmux / capture failed / empty capture / no hasher / +# unusable state dir / corrupt state / clock skew) — the caller MUST +# treat unknown as "not stalled", never kill a worker on it +# +# A worker busy in its Stop hooks is NOT a stall: measured, that state renders a +# ticking counter ("running stop hooks… 6/7 · 1m 36s"), so the pane keeps +# changing and this reports progressing. Hook chains of ~2 minutes were measured; +# the 600s default leaves ample room above them. +# +# env (also test hooks): +# LO_STALL_SEC silence threshold in seconds (default 600 = 10 min, +# matching orca-worker-stalled.sh's ORCA_STALL_MS) +# LO_STALL_TMUX tmux executable (default: the one on PATH) +# LO_STALL_STATE_DIR where per-session state lives +# (default: ${XDG_STATE_HOME:-$HOME/.local/state}/dev-loop/stall) +# LO_STALL_NOW override "now" in epoch seconds (tests) +# +# state file: /.stall, one line " " +set -u + +sess="${1:-}" +[ -n "$sess" ] || { echo "usage: tmux-worker-stalled.sh " >&2; exit 2; } +# The session name becomes a filename below. Refuse anything that could escape +# the state dir rather than writing outside it. +case "$sess" in + */*|.|..) echo "tmux-worker-stalled: invalid session name '$sess'" >&2; exit 2 ;; +esac + +thr="${LO_STALL_SEC:-600}" +case "$thr" in ''|*[!0-9]*) exit 2 ;; esac +[ "$thr" -gt 0 ] || exit 2 + +now="${LO_STALL_NOW:-}" +if [ -z "$now" ]; then + # `date +%s` is POSIX; `date +%N` is GNU-only, so this stays second-granular. + now=$(date +%s 2>/dev/null) || exit 2 +fi +case "$now" in ''|*[!0-9]*) exit 2 ;; esac + +TMUX_BIN="${LO_STALL_TMUX:-$(command -v tmux 2>/dev/null || true)}" +[ -n "$TMUX_BIN" ] && [ -x "$TMUX_BIN" ] || exit 2 + +# A dead session is not a stall — watch-status.sh's liveness check owns that, and +# reporting it here too would double-count one failure as two. +"$TMUX_BIN" has-session -t "$sess" /dev/null || exit 0 + +pane=$("$TMUX_BIN" capture-pane -t "$sess" -p /dev/null) || exit 2 +# An empty capture is "cannot read the pane", not "the pane is blank and calm". +[ -n "$pane" ] || exit 2 + +# md5sum is GNU, md5 is BSD; resolve once and refuse rather than ever comparing +# unhashed content, which would make every long pane compare unequal by accident. +hash="" +if command -v md5sum >/dev/null 2>&1; then + hash=$(printf '%s' "$pane" | md5sum 2>/dev/null | cut -d' ' -f1) +elif command -v md5 >/dev/null 2>&1; then + hash=$(printf '%s' "$pane" | md5 -q 2>/dev/null) +fi +case "$hash" in ''|*[!0-9a-fA-F]*) exit 2 ;; esac + +state_dir="${LO_STALL_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/dev-loop/stall}" +mkdir -p "$state_dir" 2>/dev/null || exit 2 +[ -w "$state_dir" ] || exit 2 +state="$state_dir/$sess.stall" + +record() { printf '%s %s\n' "$1" "$2" > "$state" 2>/dev/null || return 1; } + +# First observation: record it and report progressing. A single sample carries no +# information about silence, so it can never be a stall. +[ -f "$state" ] || { record "$hash" "$now" || exit 2; exit 0; } + +read -r prev_hash prev_ts _rest < "$state" 2>/dev/null || { record "$hash" "$now"; exit 2; } +# Corrupt state tells us nothing about how long the pane has been still. Report +# unknown, but re-seed it so the next poll has a usable baseline (self-healing). +if [ -z "${prev_hash:-}" ] || [ -z "${prev_ts:-}" ]; then record "$hash" "$now"; exit 2; fi +case "$prev_ts" in ''|*[!0-9]*) record "$hash" "$now"; exit 2 ;; esac + +# The pane moved: reset the clock on the new content. +if [ "$prev_hash" != "$hash" ]; then + record "$hash" "$now" || exit 2 + exit 0 +fi + +# Same content. `now` and the stored stamp are both floor-to-second, so a tiny +# backwards step is precision, not a real move; a large one means the clock was +# adjusted and the elapsed time is not trustworthy. +if [ "$prev_ts" -gt "$now" ]; then + [ $((prev_ts - now)) -le 5 ] || exit 2 + silent=0 +else + silent=$((now - prev_ts)) +fi + +if [ "$silent" -ge "$thr" ]; then + echo "[stalled] $sess — pane unchanged for ${silent}s (threshold ${thr}s)" + exit 1 +fi +exit 0 diff --git a/skills/orchestrate/scripts/watch-status.sh b/skills/orchestrate/scripts/watch-status.sh index 26a092c..eafacd3 100755 --- a/skills/orchestrate/scripts/watch-status.sh +++ b/skills/orchestrate/scripts/watch-status.sh @@ -7,10 +7,28 @@ # exit 0: all reached target (or higher) # exit 2: timeout # exit 3: a failed session detected (abort → orchestrator intervenes) +# exit 4: bad arguments (unknown target phase, missing dir, bad LO_PHASE_TIMEOUTS) # exit 5: an escalation is pending (a worker's guardrails `ask` needs approval) +# +# Per-phase deadlines: one flat timeout gave a plan phase and a long implement +# phase the same budget. LO_PHASE_TIMEOUTS carries a per-phase budget keyed on +# the TARGET phase of this wait, so one exported value serves every call: +# +# LO_PHASE_TIMEOUTS="plan_ready=900,impl_done=3600,done=1800" +# +# Precedence, highest first: +# 1. an explicitly passed [timeout-sec] argument (source=arg) +# 2. the entry matching (source=LO_PHASE_TIMEOUTS) +# 3. the 3600s default (source=default) +# A malformed entry (no `=`, non-numeric, <= 0, or a phase name that is not in +# the phase order) is refused with exit 4 rather than silently defaulted — the +# same treatment an unknown target phase already gets, and for the same reason: +# a typo must not quietly buy a wildly wrong deadline. The effective budget and +# its source are printed before the wait and repeated in the TIMEOUT line. set -eu JQ=$(command -v jq) || { echo "watch-status: jq not found" >&2; exit 127; } +argc=$# dir="$1"; target="$2"; expected="$3"; timeout="${4:-3600}"; interval="${5:-15}" # monotonic phase order (low->high); failed handled separately @@ -22,6 +40,37 @@ target_rank=$(rank "$target") if [ "$target_rank" -lt 0 ]; then echo "watch-status: unknown target phase '$target'" >&2; exit 4; fi [ -d "$dir" ] || { echo "watch-status: status dir '$dir' does not exist" >&2; exit 4; } +# ---- effective budget (see the header for the precedence rules) ------------- +budget=3600; budget_src=default +if [ -n "${LO_PHASE_TIMEOUTS:-}" ]; then + # Split on commas via $@ rather than looping with IFS=',' still set: `rank` below + # splits $order on whitespace, so IFS must be back to normal inside the loop. + # `set -f` keeps an entry from being glob-expanded on the way in. + oldifs=$IFS; IFS=','; set -f + # shellcheck disable=SC2086 + set -- $LO_PHASE_TIMEOUTS + set +f; IFS=$oldifs + for entry in "$@"; do + [ -n "$entry" ] || continue # tolerate a trailing/doubled comma + case "$entry" in + *=*) : ;; + *) echo "watch-status: invalid LO_PHASE_TIMEOUTS entry '$entry' (expected phase=seconds)" >&2; exit 4 ;; + esac + ph=${entry%%=*}; val=${entry#*=} + case "$val" in + ''|*[!0-9]*) echo "watch-status: invalid LO_PHASE_TIMEOUTS entry '$entry' (seconds must be a positive integer)" >&2; exit 4 ;; + esac + [ "$val" -gt 0 ] || { echo "watch-status: invalid LO_PHASE_TIMEOUTS entry '$entry' (seconds must be > 0)" >&2; exit 4; } + [ "$(rank "$ph")" -ge 0 ] || { echo "watch-status: invalid LO_PHASE_TIMEOUTS entry '$entry' (unknown phase '$ph')" >&2; exit 4; } + if [ "$ph" = "$target" ]; then budget="$val"; budget_src=LO_PHASE_TIMEOUTS; fi + done +fi +# An explicitly passed argument is the caller's direct instruction and outranks +# the environment's policy. $# alone cannot say whether the 4th argument was +# given or defaulted, so the count is captured before anything consumes it. +if [ "$argc" -ge 4 ]; then budget="$timeout"; budget_src=arg; fi +echo "[watch] budget=${budget}s target=${target} source=${budget_src}" + elapsed=0 # Escalations live beside the status dir: /.orchestration/{status,escalations}. escdir="$(dirname "$dir")/escalations" @@ -30,7 +79,7 @@ escdir="$(dirname "$dir")/escalations" # dead — a missing tmux must not abort the run (`! missing-cmd` would invert to true). TMUX_BIN="${WATCH_TMUX:-tmux}" command -v "$TMUX_BIN" >/dev/null 2>&1 || TMUX_BIN="" -while [ "$elapsed" -lt "$timeout" ]; do +while [ "$elapsed" -lt "$budget" ]; do # A worker's guardrails `ask`, recorded as an escalation, wakes the coordinator # immediately rather than waiting out the timeout. The coordinator MUST resolve # (approve/deny) and clear these records before relaunching watch; exit 5 recurs @@ -77,4 +126,4 @@ while [ "$elapsed" -lt "$timeout" ]; do [ "$done_count" -ge "$expected" ] && { echo "[watch] all reached $target"; exit 0; } sleep "$interval"; elapsed=$((elapsed+interval)) done -echo "[watch] TIMEOUT (${timeout}s):$summary"; exit 2 +echo "[watch] TIMEOUT (${budget}s, source=${budget_src}):$summary"; exit 2 diff --git a/skills/orchestrate/scripts/worker-guardrails.sh b/skills/orchestrate/scripts/worker-guardrails.sh index 3d02aa7..147c978 100644 --- a/skills/orchestrate/scripts/worker-guardrails.sh +++ b/skills/orchestrate/scripts/worker-guardrails.sh @@ -8,6 +8,15 @@ # turns into a coordinator escalation rather than a hard block. Unknown rule ids # are ignored by older guardrails, so worktree_escape is forward-compatible. # +# worktree_escape stays `ask` — `allowPaths` declares the ONE sanctioned path +# (`.orchestration`, the coordination dir a worker writes its status and plan into) +# rather than disabling the rule. Without it every such write reads as a write into +# the main worktree: measured 2026-08-05, three escalations in one run, each +# aborting the watch (exit 5) for legitimate coordination writes. Older guardrails +# read only `.rules[].mode` (guardrails 1.0.0 hooks/bash-guard.sh, inspected +# 2026-08-05), so they ignore the extra key — safe to ship before the release that +# implements it. Paths are relative to the MAIN worktree root. +# # This is the SINGLE source of the worker config. `setup-worktrees.sh` calls it # for the tmux substrate; on the Orca substrate the worktree is created by # `orca worktree create`, so the orchestrator calls this script directly with the @@ -23,7 +32,7 @@ wt="${1:-}" mkdir -p "$wt/.groundwork" cat > "$wt/.groundwork/guardrails.json" <<'GRJSON' -{"rules":{"rm_rf":{"mode":"off"},"git_discard":{"mode":"off"},"system_tmp_write":{"mode":"off"},"cloud_delete":{"mode":"ask"},"sql_drop":{"mode":"ask"},"git_force_push":{"mode":"ask"},"secret_export":{"mode":"ask"},"curl_pipe_shell":{"mode":"ask"},"worktree_escape":{"mode":"ask"}}} +{"rules":{"rm_rf":{"mode":"off"},"git_discard":{"mode":"off"},"system_tmp_write":{"mode":"off"},"cloud_delete":{"mode":"ask"},"sql_drop":{"mode":"ask"},"git_force_push":{"mode":"ask"},"secret_export":{"mode":"ask"},"curl_pipe_shell":{"mode":"ask"},"worktree_escape":{"mode":"ask","allowPaths":[".orchestration"]}}} GRJSON # Keep the sandbox config out of commits (worktree-local git exclude). Create the diff --git a/skills/orchestrate/templates/session-prompt.md b/skills/orchestrate/templates/session-prompt.md index 8bd915c..29878e9 100644 --- a/skills/orchestrate/templates/session-prompt.md +++ b/skills/orchestrate/templates/session-prompt.md @@ -15,6 +15,47 @@ unsubstituted and the worker reads it from its own Orca dispatch context. **tmux substrate.** §1–§4 — one line each, `send-keys -l`. +## tmux delivery — how §1–§4 reach a worker + +§1 is injected by `launch-session.sh`, which owns the trust screen and the first +injection. Do not re-implement that path. + +Every prompt after §1 — §2, §3, §4 — goes to the already-running session through +`send-prompt.sh`. Raw `send-keys` returns 0 as soon as the keys reach the pane, +which does not mean the worker took them: measured here, a prompt sent to a busy +worker sat queued and invisible for 55s while a Stop-hook chain drained. + +``` +sh {SKILL}/scripts/send-prompt.sh send "" +sh {SKILL}/scripts/send-prompt.sh wait [timeout] +sh {SKILL}/scripts/send-prompt.sh state +``` + +Branch on the exit code. stdout is exactly one token; stderr is advisory context +and must never be parsed. + +| exit | `send` | `wait` | `state` | +|------|--------|--------|---------| +| 0 | delivered | picked-up | ready | +| 3 | gone | gone | gone | +| 4 | queued | — | busy | +| 5 | — | deadline expired | — | +| 7 | unconfirmed | — | — | + +Shared codes: `1` usage · `2` invalid session name or prompt (injection guard) · +`6` tmux failed against a live session · `127` tmux not found. + +Exit 4 is not a failure — the worker holds the prompt but is still mid-turn. +Follow it with `wait` (default 180s): exit 0 means the worker picked it up, exit 5 +means it is still working, NOT that the prompt was lost. That distinction is the +whole point; do not retry a send on exit 5. + +Exit 7 means the keys were written but the pane showed no reaction — treat it as +unknown and query `state`, never as success. + +The queued/busy markers are the Claude CLI's own wording, not this repo's. +Override `LO_QUEUED_PATTERN` / `LO_BUSY_PATTERN` when a CLI release renames them. + ## (1) Plan — injected at session launch You are the session for {TASK}. Treat .orchestration/briefs/{TASK}.md `` as authority — especially ``, ``, and ``. Use the loop-implement skill but STOP after planning: run its step 2 with the bundled `wiki-plan` skill (make every design decision grounded in a `wiki/` page — record the decision->page map; leave nothing "as appropriate"), write the resulting implementation plan to .orchestration/plans/{TASK}.md, then run `STATUS_DIR={STATUS_DIR} sh {SKILL}/scripts/status-update.sh {TASK} plan_ready worktree=$PWD` and wait for an approval message. Do NOT write implementation code yet. diff --git a/tests/launch-session.bats b/tests/launch-session.bats index 28abc1c..a3ee257 100644 --- a/tests/launch-session.bats +++ b/tests/launch-session.bats @@ -33,3 +33,247 @@ setup() { run env LO_DRY_RUN=1 bash "$LS" lo-1 "${BATS_TEST_TMPDIR}/wt" "bad; rm -rf ~" "prompt" [ "$status" -eq 2 ] } + +# --------------------------------------------------------------------------- +# The tests below drive the real launch path by replacing only the two external +# binaries (tmux, claude) via the LO_TMUX / LO_CLAUDE hooks. Assertions are +# always on what launch-session.sh *did* — its exit code, its message, which +# keys it sent, how many times it polled — never on the stub's canned bytes. +# +# The script is `#!/bin/sh`; these invoke it with `sh`, not `bash`, or the +# shebang is overridden and a bashism would pass here while failing under dash +# in production. (The five tests above predate this file's stub harness and are +# left exactly as they were.) +# +# The executable stub lives under the repo's gitignored .claude/tmp — never +# $TMPDIR or /tmp, where creating and chmod +x-ing an executable is forbidden +# by policy. This mirrors tests/orca-wait.bats. +# --------------------------------------------------------------------------- + +REPO_TMP() { printf '%s' "${BATS_TEST_DIRNAME}/../.claude/tmp"; } + +mk_tmux_stub() { # -> path of an executable `tmux` test double + mkdir -p "$(REPO_TMP)" + local stub="$(REPO_TMP)/tmux-stub-${BATS_TEST_NUMBER}" + cat > "$stub" <<'STUBEOF' +#!/bin/sh +# Test double for the `tmux` CLI. All state under $STUB_DIR: +# env-log one line per call: TMUX= ARGV= +# captures counter of capture-pane calls +# pane pane text served by capture-pane (absent -> empty) +# pane- pane text served on the nth capture-pane call (wins over `pane`) +# keys one line per send-keys call (its arguments) +# has-session-rc exit code for has-session (absent -> 1, "no such session") +set -u +d="${STUB_DIR:?}" +printf 'TMUX=%s ARGV=%s\n' "${TMUX-}" "$*" >> "$d/env-log" +case "${1:-}" in + has-session) + exit "$(cat "$d/has-session-rc" 2>/dev/null || echo 1)" ;; + capture-pane) + n=$(cat "$d/captures" 2>/dev/null || echo 0); n=$((n + 1)); echo "$n" > "$d/captures" + if [ -f "$d/pane-$n" ]; then cat "$d/pane-$n" + elif [ -f "$d/pane" ]; then cat "$d/pane"; fi ;; + send-keys) + shift; printf '%s\n' "$*" >> "$d/keys" ;; +esac +exit 0 +STUBEOF + chmod +x "$stub" + printf '%s' "$stub" +} + +teardown() { rm -f "$(REPO_TMP)/tmux-stub-${BATS_TEST_NUMBER}"; } + +# --- pane fixtures, transcribed from real `capture-pane` output of a live +# --- Claude CLI in tmux (measured 2026-08-05). The only edits are trimming +# --- blank filler lines and shortening the rules. +RULE='────────────────────────────────────────' + +pane_ready_submitted() { # prompt accepted: the input box is empty + printf '%s\n' \ + "✶ Transfiguring… (running stop hooks… 6/7 · 5s · ↓ 13 tokens)" \ + " ● high · /effort" \ + "$RULE" \ + "❯ " \ + "$RULE" \ + "" \ + " ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" +} + +pane_ready_unsubmitted() { # THE BUG: prompt typed into the box, never submitted + printf '%s\n' \ + " ● high · /effort" \ + "$RULE" \ + "❯ ZZPROMPTHEAD you are the worker session for probe-task and this is a long" \ + " one-line prompt that wrapped onto a second rendered line" \ + "$RULE" \ + "" \ + " ⏵⏵ bypass permissions on (shift+tab to cycle)" +} + +pane_ready_pasted() { # the exact field rendering: the CLI collapsed it to a paste + printf '%s\n' \ + " ● high · /effort" \ + "$RULE" \ + "❯ [Pasted text #1 +1 lines]" \ + "$RULE" \ + "" \ + " ⏵⏵ bypass permissions on (shift+tab to cycle)" +} + +pane_not_ready() { # matches none of the ready/trust patterns + printf '%s\n' "Loading…" "NOTREADYMARKER" +} + +# --- Task 01: launch-session.sh must not clobber tmux's own TMUX variable --- +# TMUX is tmux's server-socket variable. Assigning it exports the corruption to +# every child tmux, which then tries to connect to a binary path as a socket. +# Measured 2026-08-05: this breaks EVERY launch when the coordinator itself runs +# inside tmux ("error connecting to (Socket operation on non-socket)"). + +@test "TMUX: the caller's tmux socket reaches the child untouched" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd"; echo 0 > "$sd/has-session-rc" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + TMUX=SENTINEL-SOCKET,111,0 sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "p" + [ "$status" -eq 0 ] + grep -q 'TMUX=SENTINEL-SOCKET,111,0' "$sd/env-log" +} + +@test "TMUX: the tmux binary path is never assigned over the socket (regression)" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd"; echo 0 > "$sd/has-session-rc" + stub="$(mk_tmux_stub)" + run env STUB_DIR="$sd" LO_TMUX="$stub" LO_CLAUDE=/bin/echo \ + TMUX=SENTINEL-SOCKET,111,0 sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "p" + [ "$status" -eq 0 ] + # the assertion that fails on the pre-fix script: + ! grep -q "TMUX=$stub" "$sd/env-log" +} + +@test "TMUX: a caller with no TMUX set still launches (boundary — masks the bug)" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd"; echo 0 > "$sd/has-session-rc" + run env -u TMUX STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "p" + [ "$status" -eq 0 ] + grep -q 'TMUX=' "$sd/env-log" +} + +# --- Task 02: the readiness budget is honoured exactly, and reports --- + +@test "LO_READY_TIMEOUT is honoured exactly: floor(timeout/interval) attempts" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd"; pane_not_ready > "$sd/pane" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=3 LO_READY_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "p" + [ "$status" -eq 4 ] + [ "$(cat "$sd/captures")" = "3" ] +} + +@test "a readiness timeout exits 4 and says the REPL was not ready (error contract)" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd"; pane_not_ready > "$sd/pane" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=2 LO_READY_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "p" + [ "$status" -eq 4 ] + [[ "$output" == *"REPL not ready"* ]] +} + +@test "a readiness timeout reports the last screen, not just the failure" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd"; pane_not_ready > "$sd/pane" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=2 LO_READY_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "p" + [ "$status" -eq 4 ] + [[ "$output" == *"NOTREADYMARKER"* ]] +} + +@test "a sub-interval readiness budget still makes exactly one attempt (boundary)" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd"; pane_not_ready > "$sd/pane" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=1 LO_READY_INTERVAL=2 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "p" + [ "$status" -eq 4 ] + [ "$(cat "$sd/captures")" = "1" ] +} + +# --- Task 05: submission confirmation (field finding, 2026-08-05) --- +# send-keys exit 0 means "keys reached the pane", NOT "the worker got the +# prompt". A prompt sat unsubmitted in the input box for 45 minutes while the +# launcher printed "ok". Confirm submission, or report a distinct outcome. + +@test "a submitted prompt is confirmed and reported as submitted" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd" + pane_ready_submitted > "$sd/pane" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=2 LO_READY_INTERVAL=1 LO_SUBMIT_TIMEOUT=2 LO_SUBMIT_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "ZZPROMPTHEAD hello" + [ "$status" -eq 0 ] + [[ "$output" == *"submitted"* ]] +} + +@test "a prompt left sitting in the input box is NOT reported as success (exit 5)" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd" + pane_ready_unsubmitted > "$sd/pane" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=2 LO_READY_INTERVAL=1 LO_SUBMIT_TIMEOUT=2 LO_SUBMIT_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "ZZPROMPTHEAD you are the worker" + [ "$status" -eq 5 ] + [[ "$output" != *"ok:"* ]] + [[ "$output" == *"NOT confirmed submitted"* ]] +} + +@test "the field's [Pasted text] rendering is caught too, though the prompt text is hidden" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd" + pane_ready_pasted > "$sd/pane" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=2 LO_READY_INTERVAL=1 LO_SUBMIT_TIMEOUT=2 LO_SUBMIT_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "ZZPROMPTHEAD you are the worker" + [ "$status" -eq 5 ] + [[ "$output" == *"NOT confirmed submitted"* ]] +} + +@test "an unconfirmed submission reports the last screen so the pane is diagnosable" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd" + pane_ready_pasted > "$sd/pane" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=2 LO_READY_INTERVAL=1 LO_SUBMIT_TIMEOUT=2 LO_SUBMIT_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "ZZPROMPTHEAD hello" + [ "$status" -eq 5 ] + [[ "$output" == *"Pasted text"* ]] +} + +@test "an unsubmitted prompt gets the known remedy — one more Enter — then confirms" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd" + # ready check consumes capture #1; the box is still full at #2, empty at #3. + pane_ready_unsubmitted > "$sd/pane-1" + pane_ready_unsubmitted > "$sd/pane-2" + pane_ready_submitted > "$sd/pane-3" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=2 LO_READY_INTERVAL=1 LO_SUBMIT_TIMEOUT=6 LO_SUBMIT_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "ZZPROMPTHEAD you are the worker" + [ "$status" -eq 0 ] + # the initial Enter plus exactly one remedial Enter + [ "$(grep -c '^-t lo-1 Enter$' "$sd/keys")" = "2" ] +} + +@test "a pane that cannot be captured is never read as a successful submission" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd" + # ready screen for the readiness check, then nothing at all (session vanished) + pane_ready_submitted > "$sd/pane-1" + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=2 LO_READY_INTERVAL=1 LO_SUBMIT_TIMEOUT=2 LO_SUBMIT_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "ZZPROMPTHEAD hello" + [ "$status" -eq 5 ] +} + +@test "submission confirmation is bounded: it does not outlive LO_SUBMIT_TIMEOUT (boundary)" { + sd="$BATS_TEST_TMPDIR/sd"; mkdir -p "$sd" + pane_ready_unsubmitted > "$sd/pane" + start=$(date +%s) + run env STUB_DIR="$sd" LO_TMUX="$(mk_tmux_stub)" LO_CLAUDE=/bin/echo \ + LO_READY_TIMEOUT=1 LO_READY_INTERVAL=1 LO_SUBMIT_TIMEOUT=2 LO_SUBMIT_INTERVAL=1 \ + sh "$LS" lo-1 "$BATS_TEST_TMPDIR" bypassPermissions "ZZPROMPTHEAD hello" + elapsed=$(( $(date +%s) - start )) + [ "$status" -eq 5 ] + [ "$elapsed" -lt 10 ] +} diff --git a/tests/safe-cleanup.bats b/tests/safe-cleanup.bats index 5a4ba09..61eca31 100644 --- a/tests/safe-cleanup.bats +++ b/tests/safe-cleanup.bats @@ -3,6 +3,72 @@ setup() { SC="${BATS_TEST_DIRNAME}/../skills/orchestrate/scripts/safe-cleanup.sh" + # Per-test scratch root for the injected tmux fake. Kept INSIDE the repo + # (.claude/ is git-ignored) rather than under $BATS_TEST_TMPDIR, because the + # fake needs an exec bit and host EDR flags chmod +x under /tmp,$TMPDIR. + STUB_ROOT="${BATS_TEST_DIRNAME}/../.claude/tmp/sc-$$-${BATS_TEST_NUMBER:-0}" +} + +teardown() { + [ -n "${STUB_ROOT:-}" ] && rm -rf "$STUB_ROOT" + return 0 +} + +# Assertion helpers. NOTE: a bare `[[ ... ]]` mid-test-body does NOT fail a bats +# test (bash exempts the [[ keyword from the ERR trap), so substring assertions +# written that way are decorative. These are plain function calls, whose failure +# DOES abort the test — and they print what was expected vs. actual. +_has() { + case "$1" in + *"$2"*) return 0 ;; + *) printf 'expected to find [%s] in [%s]\n' "$2" "$1" >&2; return 1 ;; + esac +} +_hasnt() { + case "$1" in + *"$2"*) printf 'did NOT expect [%s] in [%s]\n' "$2" "$1" >&2; return 1 ;; + *) return 0 ;; + esac +} + +# Install a recording tmux fake on PATH for THIS test only (never globally: the +# pre-existing kill-sessions test relies on the real tmux reporting "no such +# session"). Records every kill target and the $TMUX it was invoked with. +_use_fake_tmux() { + mkdir -p "$STUB_ROOT/bin" + FAKE_KILL_LOG="$STUB_ROOT/kills.log"; : > "$FAKE_KILL_LOG" + FAKE_ENV_LOG="$STUB_ROOT/env.log"; : > "$FAKE_ENV_LOG" + export FAKE_KILL_LOG FAKE_ENV_LOG + cat > "$STUB_ROOT/bin/tmux" <<'FAKE' +#!/bin/sh +printf 'TMUXENV=%s\n' "${TMUX:-}" >> "$FAKE_ENV_LOG" +verb="$1"; shift +case "$verb" in + list-sessions) printf '%s' "${FAKE_ROSTER:-}" ;; + has-session) exit 0 ;; + kill-session) + while [ $# -gt 0 ]; do + case "$1" in -t) shift; printf '%s\n' "$1" >> "$FAKE_KILL_LOG"; break ;; *) shift ;; esac + done ;; +esac +exit 0 +FAKE + chmod +x "$STUB_ROOT/bin/tmux" + PATH="$STUB_ROOT/bin:$PATH"; export PATH +} + +# Count non-empty lines in the kill log (0 when nothing was killed). +_kill_count() { grep -c . "$FAKE_KILL_LOG" 2>/dev/null || true; } + +# Minimal committed git repo; echoes its path. `sweep` prunes worktrees, so its +# must be a real repo even when a test only exercises the session half. +_mkroot() { + _p="${BATS_TEST_TMPDIR}/root-${1:-0}" + mkdir -p "$_p" + git -C "$_p" init -q + git -C "$_p" config user.email t@t; git -C "$_p" config user.name t + echo x > "$_p/f"; git -C "$_p" add f; git -C "$_p" commit -qm init + printf '%s' "$_p" } @test "init-check: refuse inside an existing repo (nesting)" { @@ -44,3 +110,349 @@ setup() { [ "$status" -eq 0 ] [[ "$output" == *"skip"* ]] } + +# --------------------------------------------------------------------------- +# Task 01 — --dry-run on the existing destructive verbs. +# New tests invoke the script with `sh`, never `bash`: `bash script.sh` overrides +# the #!/bin/sh shebang, so a bashism would pass here and fail under dash in +# production (wiki: platforms-shells-portable-shell-scripts). +# --------------------------------------------------------------------------- + +# Build a repo with one commit, a task branch, and its worktree. Echoes the +# default branch name so tests never hard-code main-vs-master. +_mkrepo() { + _r="$1" + mkdir -p "$_r" + git -C "$_r" init -q + git -C "$_r" config user.email t@t; git -C "$_r" config user.name t + echo x > "$_r/f"; git -C "$_r" add f; git -C "$_r" commit -qm init + git -C "$_r" branch feat/t1 + git -C "$_r" worktree add -q "$_r/.worktrees/feat-t1" feat/t1 + git -C "$_r" rev-parse --abbrev-ref HEAD +} + +@test "dry-run: remove-worktrees prints the plan and removes nothing" { + root="${BATS_TEST_TMPDIR}/dr1"; _mkrepo "$root" >/dev/null + run sh "$SC" remove-worktrees --dry-run "$root" feat/t1 + [ "$status" -eq 0 ] + _has "$output" "would remove" + _hasnt "$output" "removed:" + # the resource must still be there — an exit-0 assertion alone cannot fail + [ -d "$root/.worktrees/feat-t1" ] + git -C "$root" worktree list | grep -q "feat-t1" +} + +@test "dry-run: merge prints the plan and leaves the integration branch at the same SHA" { + root="${BATS_TEST_TMPDIR}/dr2"; defbr=$(_mkrepo "$root") + before=$(git -C "$root" rev-parse "$defbr") + run sh "$SC" merge --dry-run "$root" "$defbr" feat/t1 + [ "$status" -eq 0 ] + _has "$output" "would merge" + after=$(git -C "$root" rev-parse "$defbr") + [ "$before" = "$after" ] +} + +@test "dry-run: merge still REFUSEs a dirty worktree (dry-run must not soften a refusal)" { + root="${BATS_TEST_TMPDIR}/dr3"; defbr=$(_mkrepo "$root") + echo dirty > "$root/.worktrees/feat-t1/g" # untracked -> dirty + run sh "$SC" merge --dry-run "$root" "$defbr" feat/t1 + [ "$status" -ne 0 ] + _has "$output" "REFUSE" +} + +@test "dry-run: boundary — a trailing --dry-run is parsed as a flag, not as a branch name" { + root="${BATS_TEST_TMPDIR}/dr4"; _mkrepo "$root" >/dev/null + run sh "$SC" remove-worktrees "$root" feat/t1 --dry-run + [ "$status" -eq 0 ] + _has "$output" "would remove" + # if the flag had been consumed as an operand, DRY would be 0 and this is gone + [ -d "$root/.worktrees/feat-t1" ] +} + +@test "dry-run: kill-sessions announces the kill but issues none" { + _use_fake_tmux + run sh "$SC" kill-sessions --dry-run "lo-1-runA" + [ "$status" -eq 0 ] + _has "$output" "would kill" + _hasnt "$output" "killed:" + [ "$(_kill_count)" -eq 0 ] +} + +@test "kill-sessions: actually kills the exact name it is given" { + _use_fake_tmux + run sh "$SC" kill-sessions "lo-1-runA" + [ "$status" -eq 0 ] + _has "$output" "killed:" + [ "$(_kill_count)" -eq 1 ] + _has "$(cat "$FAKE_KILL_LOG")" "lo-1-runA" +} + +@test "kill-sessions: does not clobber tmux's own \$TMUX socket variable" { + # Regression: `TMUX=\$(command -v tmux)` inherits the exported attribute when + # the script runs INSIDE a tmux session (as every orchestrator worker does), + # so tmux then tries to use the binary path as its server socket and every + # has-session call fails -> teardown silently kills nothing. + _use_fake_tmux + export TMUX="/private/tmp/tmux-501/default,111,2" + run sh "$SC" kill-sessions "lo-1-runA" + [ "$status" -eq 0 ] + _has "$(cat "$FAKE_ENV_LOG")" "TMUXENV=/private/tmp/tmux-501/default,111,2" + _hasnt "$(cat "$FAKE_ENV_LOG")" "TMUXENV=$STUB_ROOT/bin/tmux" +} + +# --------------------------------------------------------------------------- +# Task 02 — `sweep`: destructive, and scoped to LO_RUN_ID. +# Every one of these drives the INJECTED tmux fake, never the real server: this +# machine runs live lo-N- orchestration sessions, and a scoping bug (the +# exact defect under test) would otherwise tear down the running workers. +# --------------------------------------------------------------------------- + +@test "sweep: kills exactly this run's sessions" { + _use_fake_tmux + export FAKE_ROSTER='lo-1-runA +lo-2-runA +lo-9-runB +lo-test +mydev' + export LO_RUN_ID=runA + run sh "$SC" sweep "$(_mkroot sw)" + [ "$status" -eq 0 ] + killed=$(cat "$FAKE_KILL_LOG") + _has "$killed" "lo-1-runA" + _has "$killed" "lo-2-runA" + [ "$(_kill_count)" -eq 2 ] +} + +@test "sweep: two run ids present — the foreign run is never touched" { + _use_fake_tmux + export FAKE_ROSTER='lo-1-runA +lo-2-runA +lo-9-runB +lo-test +mydev' + export LO_RUN_ID=runA + run sh "$SC" sweep "$(_mkroot sw)" + [ "$status" -eq 0 ] + killed=$(cat "$FAKE_KILL_LOG") + _hasnt "$killed" "lo-9-runB" + _hasnt "$killed" "lo-test" + _hasnt "$killed" "mydev" +} + +@test "sweep: error — REFUSEs when LO_RUN_ID is unset and kills nothing" { + _use_fake_tmux + export FAKE_ROSTER='lo-1-runA +lo-2-runA' + unset LO_RUN_ID + run sh "$SC" sweep "$(_mkroot sw)" + [ "$status" -ne 0 ] + _has "$output" "REFUSE" + [ "$(_kill_count)" -eq 0 ] +} + +@test "sweep: error — REFUSEs a malformed LO_RUN_ID and kills nothing" { + _use_fake_tmux + export FAKE_ROSTER='lo-1-runA' + export LO_RUN_ID='a b' + run sh "$SC" sweep "$(_mkroot sw)" + [ "$status" -ne 0 ] + _has "$output" "REFUSE" + [ "$(_kill_count)" -eq 0 ] +} + +@test "sweep: boundary — a run id that is a prefix of another id is not swept" { + _use_fake_tmux + # lo-1-runAB: runA is a prefix of runAB, must NOT match. + # lo-1-x-runA: ends with -runA but belongs to a differently-named run. + export FAKE_ROSTER='lo-1-runAB +lo-1-x-runA +lo-3-runA' + export LO_RUN_ID=runA + run sh "$SC" sweep "$(_mkroot sw)" + [ "$status" -eq 0 ] + killed=$(cat "$FAKE_KILL_LOG") + _hasnt "$killed" "lo-1-runAB" + _hasnt "$killed" "lo-1-x-runA" + _has "$killed" "lo-3-runA" + [ "$(_kill_count)" -eq 1 ] +} + +@test "sweep: boundary — an empty session roster is a clean no-op" { + _use_fake_tmux + export FAKE_ROSTER='' + export LO_RUN_ID=runA + run sh "$SC" sweep "$(_mkroot sw)" + [ "$status" -eq 0 ] + _has "$output" "0 session" + [ "$(_kill_count)" -eq 0 ] +} + +@test "sweep: --dry-run announces the kills and issues none" { + _use_fake_tmux + export FAKE_ROSTER='lo-1-runA +lo-9-runB' + export LO_RUN_ID=runA + run sh "$SC" sweep --dry-run "$(_mkroot swdry)" + [ "$status" -eq 0 ] + _has "$output" "would kill: lo-1-runA" + _hasnt "$output" "killed: lo-1-runA" + [ "$(_kill_count)" -eq 0 ] +} + +@test "sweep: error — a missing repo-root argument is a usage error" { + _use_fake_tmux + export LO_RUN_ID=runA + run sh "$SC" sweep + [ "$status" -ne 0 ] + [ "$(_kill_count)" -eq 0 ] +} + +# --------------------------------------------------------------------------- +# Task 03 — `git worktree prune` where it belongs. +# A worktree whose DIRECTORY is gone leaves git admin files behind forever; +# that is the orphan sweep removes. Live/registered worktrees are never touched +# by sweep — removing those stays an explicit `remove-worktrees `. +# --------------------------------------------------------------------------- + +# Repo with a worktree whose directory has been deleted, leaving a stale +# .git/worktrees/ admin entry. +_mkstale() { + _r="$1" + mkdir -p "$_r" + git -C "$_r" init -q + git -C "$_r" config user.email t@t; git -C "$_r" config user.name t + echo x > "$_r/f"; git -C "$_r" add f; git -C "$_r" commit -qm init + git -C "$_r" branch feat/a + git -C "$_r" worktree add -q "$_r/.worktrees/feat-a" feat/a + rm -rf "$_r/.worktrees/feat-a" # the orphan: dir gone, admin entry stays +} + +@test "prune: remove-worktrees clears the admin entry of a vanished worktree" { + root="${BATS_TEST_TMPDIR}/pr1"; _mkstale "$root" + [ -e "$root/.git/worktrees/feat-a" ] # the orphan exists before + run sh "$SC" remove-worktrees "$root" feat/a + [ "$status" -eq 0 ] + [ ! -e "$root/.git/worktrees/feat-a" ] # ...and is gone after +} + +@test "prune: --dry-run reports the prune and leaves the admin entry in place" { + root="${BATS_TEST_TMPDIR}/pr2"; _mkstale "$root" + run sh "$SC" remove-worktrees --dry-run "$root" feat/a + [ "$status" -eq 0 ] + _has "$output" "prune:" + [ -e "$root/.git/worktrees/feat-a" ] # unchanged — this is the whole point +} + +@test "prune: sweep prunes this run's stale worktree admin entries" { + _use_fake_tmux + export FAKE_ROSTER='' + export LO_RUN_ID=runA + root="${BATS_TEST_TMPDIR}/pr3"; _mkstale "$root" + run sh "$SC" sweep "$root" + [ "$status" -eq 0 ] + [ ! -e "$root/.git/worktrees/feat-a" ] +} + +@test "prune: error — sweep on a non-git root fails instead of reporting success" { + _use_fake_tmux + export FAKE_ROSTER='' + export LO_RUN_ID=runA + root="${BATS_TEST_TMPDIR}/pr4"; mkdir -p "$root" + run sh "$SC" sweep "$root" + [ "$status" -ne 0 ] +} + +@test "prune: boundary — a repo with no worktrees prunes nothing and stays quiet" { + root="${BATS_TEST_TMPDIR}/pr5"; mkdir -p "$root" + git -C "$root" init -q + git -C "$root" config user.email t@t; git -C "$root" config user.name t + echo x > "$root/f"; git -C "$root" add f; git -C "$root" commit -qm init + git -C "$root" branch feat/t1 + run sh "$SC" remove-worktrees "$root" feat/t1 + [ "$status" -eq 0 ] + _hasnt "$output" "prune:" +} + +@test "prune: boundary — an unregistered .worktrees dir is reported, never deleted" { + _use_fake_tmux + export FAKE_ROSTER='' + export LO_RUN_ID=runA + root="${BATS_TEST_TMPDIR}/pr6"; mkdir -p "$root" + git -C "$root" init -q + git -C "$root" config user.email t@t; git -C "$root" config user.name t + echo x > "$root/f"; git -C "$root" add f; git -C "$root" commit -qm init + mkdir -p "$root/.worktrees/leftover"; echo work > "$root/.worktrees/leftover/uncommitted.txt" + run sh "$SC" sweep "$root" + [ "$status" -eq 0 ] + _has "$output" "unregistered worktree dir" + # it may hold work, so reporting is the whole contract — deletion would be a bug + [ -d "$root/.worktrees/leftover" ] + [ -f "$root/.worktrees/leftover/uncommitted.txt" ] +} + +# --------------------------------------------------------------------------- +# Task 04 — `list-orphans`: read-only, and deliberately NOT scoped to a run. +# Enumeration is unscoped so a crashed run's leaks stay discoverable (its id is +# gone from the environment); removal stays scoped to LO_RUN_ID via `sweep`. +# For these tests FAKE_ROSTER carries " " pairs, matching the +# -F format list-orphans requests. +# --------------------------------------------------------------------------- + +@test "list-orphans: annotates each session with its run id, without LO_RUN_ID set" { + _use_fake_tmux + export FAKE_ROSTER='lo-1-runA 1700000001 +lo-9-runB 1700000002 +lo-test 1700000003' + unset LO_RUN_ID + run sh "$SC" list-orphans "$(_mkroot lo1)" + [ "$status" -eq 0 ] + _has "$output" "lo-1-runA" + _has "$output" "run=runA" + _has "$output" "lo-9-runB" + _has "$output" "run=runB" + # a session with no run-id suffix must be reported, not silently dropped + _has "$output" "lo-test" + _has "$output" "run=none" + _has "$output" "created=1700000001" +} + +@test "list-orphans: reports a worktree whose directory has vanished" { + _use_fake_tmux + export FAKE_ROSTER='' + root="${BATS_TEST_TMPDIR}/lo2"; _mkstale "$root" + run sh "$SC" list-orphans "$root" + [ "$status" -eq 0 ] + _has "$output" "worktree-stale" + _has "$output" "feat-a" +} + +@test "list-orphans: error — a missing repo-root argument is a usage error" { + _use_fake_tmux + export FAKE_ROSTER='' + run sh "$SC" list-orphans + [ "$status" -ne 0 ] +} + +@test "list-orphans: boundary — nothing to report is a silent, successful run" { + _use_fake_tmux + export FAKE_ROSTER='' + run sh "$SC" list-orphans "$(_mkroot lo4)" + [ "$status" -eq 0 ] + _hasnt "$output" "session " + _hasnt "$output" "worktree-stale" + _hasnt "$output" "worktree-unregistered" +} + +@test "list-orphans: is provably read-only — kills nothing, deletes nothing" { + _use_fake_tmux + export FAKE_ROSTER='lo-1-runA 1700000001 +lo-2-runA 1700000002' + export LO_RUN_ID=runA # even with a scope set, it must not act + root="${BATS_TEST_TMPDIR}/lo5"; _mkstale "$root" + mkdir -p "$root/.worktrees/leftover" + run sh "$SC" list-orphans "$root" + [ "$status" -eq 0 ] + [ "$(_kill_count)" -eq 0 ] # no session killed + [ -e "$root/.git/worktrees/feat-a" ] # stale admin entry NOT pruned + [ -d "$root/.worktrees/leftover" ] # unregistered dir NOT deleted +} diff --git a/tests/send-prompt.bats b/tests/send-prompt.bats new file mode 100644 index 0000000..38e75e4 --- /dev/null +++ b/tests/send-prompt.bats @@ -0,0 +1,468 @@ +#!/usr/bin/env bats +# Tests for send-prompt.sh — verified coordinator->worker prompt delivery. +# +# The script is POSIX sh, so every invocation here uses `sh`, never `bash`: +# `bash script.sh` overrides the shebang and would let a bashism pass CI and +# then fail under dash in production. +# +# Every test creates its own tmux session and kills it in teardown (which bats +# runs on failure too). No test touches a session it did not create. + +# `run --separate-stderr` keeps the advisory stderr out of $output so the +# one-token stdout contract can be asserted exactly. +bats_require_minimum_version 1.5.0 + +setup() { + SP="${BATS_TEST_DIRNAME}/../skills/orchestrate/scripts/send-prompt.sh" + TPL="${BATS_TEST_DIRNAME}/../skills/orchestrate/templates/session-prompt.md" + S="t3s-$$-${BATS_TEST_NUMBER}" +} + +teardown() { + tmux kill-session -t "$S" 2>/dev/null || true +} + +# Start a throwaway session running a plain shell. A plain shell is enough to +# exercise the send/confirm mechanism; no Claude CLI is needed. +# +# The shell is pinned to /bin/sh rather than the login shell: macOS defaults to +# zsh, which does NOT treat a leading '#' as a comment interactively +# (INTERACTIVE_COMMENTS is off), while Ubuntu CI defaults to bash, which does. +# Pinning removes that divergence so the same assertions hold on both runners. +mk_session() { + tmux new-session -d -s "$S" -x 80 -y 15 /bin/sh + sleep 1 +} + +# Make the pane deliberately busy for seconds, printing a marker that stands +# in for the worker CLI's queued indicator. The trailing `clear` repaints the +# pane, which is how a TUI drops its indicator once the queue drains. +mk_busy() { + tmux send-keys -t "$S" -l -- "printf 'BUSYMARK_T3\\n'; sleep $1; clear" + tmux send-keys -t "$S" Enter + sleep 1 +} + +# ---------------------------------------------------------------- state + +@test "state: an idle live session is ready" { + mk_session + run sh "$SP" state "$S" + [ "$status" -eq 0 ] + [ "$output" = "ready" ] +} + +@test "state: a session that does not exist is gone" { + run sh "$SP" state "$S" + [ "$status" -eq 3 ] + [ "$output" = "gone" ] +} + +@test "state: a busy pane reports busy" { + mk_session + mk_busy 6 + run --separate-stderr env LO_QUEUED_PATTERN=BUSYMARK_T3 sh "$SP" state "$S" + [ "$status" -eq 4 ] + [ "$output" = "busy" ] +} + +@test "state: negative control — the same query on an idle pane is ready, not busy" { + # Proves the busy test above can fail: identical invocation, idle pane. + mk_session + run --separate-stderr env LO_QUEUED_PATTERN=BUSYMARK_T3 sh "$SP" state "$S" + [ "$status" -eq 0 ] + [ "$output" = "ready" ] +} + +@test "state: LO_BUSY_PATTERN alone also reports busy (mid-turn, nothing queued)" { + # The two markers answer different questions: LO_QUEUED_PATTERN means "a prompt + # is waiting", LO_BUSY_PATTERN means "the worker is mid-turn". state reports + # busy on either, so both arms need their own case. + mk_session + mk_busy 6 + run --separate-stderr env LO_QUEUED_PATTERN=NOSUCHMARKER LO_BUSY_PATTERN=BUSYMARK_T3 \ + sh "$SP" state "$S" + [ "$status" -eq 4 ] + [ "$output" = "busy" ] +} + +@test "boundary: a marker pattern starting with a dash is matched, not parsed as a grep flag" { + # LO_QUEUED_PATTERN is operator-supplied, so it can legitimately begin with + # '-'. Without `grep -- "$pat"` this dies "unrecognized option" and every + # busy worker silently reads as ready. + mk_session + tmux send-keys -t "$S" -l -- "printf -- '-dash-marker\\n'; sleep 6" + tmux send-keys -t "$S" Enter + sleep 1 + run --separate-stderr env LO_QUEUED_PATTERN=-dash-marker sh "$SP" state "$S" + [ "$status" -eq 4 ] + [ "$output" = "busy" ] +} + +@test "state: stdout carries exactly one token (coordinator branches on it)" { + mk_session + run --separate-stderr sh "$SP" state "$S" + [ "$status" -eq 0 ] + [ "$(printf '%s' "$output" | wc -l | tr -d ' ')" = "0" ] + [ "$(printf '%s' "$output" | wc -w | tr -d ' ')" = "1" ] +} + +@test "state: advisory pane context goes to stderr, never into the stdout token" { + # The coordinator branches on stdout+exit code; stderr is for humans. If the + # advisory leaked into stdout, a string compare against the token would break. + mk_session + mk_busy 6 + run --separate-stderr env LO_QUEUED_PATTERN=BUSYMARK_T3 sh "$SP" state "$S" + [ "$status" -eq 4 ] + [ "$output" = "busy" ] + case "$stderr" in *BUSYMARK_T3*) : ;; *) return 1 ;; esac +} + +# ---------------------------------------------------------------- usage + +@test "usage: an unknown subcommand exits 1" { + run sh "$SP" frobnicate "$S" + [ "$status" -eq 1 ] +} + +@test "usage: no arguments at all exits 1" { + run sh "$SP" + [ "$status" -eq 1 ] +} + +@test "usage: state without a session name exits 1" { + run sh "$SP" state + [ "$status" -eq 1 ] +} + +# ------------------------------------------------- injection guard (session) + +@test "injection: a session name with shell metacharacters is rejected" { + run sh "$SP" state '; rm -rf ~' + [ "$status" -eq 2 ] + [ "$output" != "ready" ] +} + +@test "injection: a session name with command substitution is rejected" { + run sh "$SP" state '$(touch /tmp/t3-pwned)' + [ "$status" -eq 2 ] +} + +@test "injection: a session name starting with a dash cannot become a tmux flag" { + # tmux parses a leading-dash argument as its own flag, so the name is rejected + # before it ever reaches the command line. + run sh "$SP" state '-X' + [ "$status" -eq 2 ] +} + +@test "boundary: an empty session name is rejected" { + run sh "$SP" state '' + [ "$status" -eq 2 ] +} + +@test "boundary: a maximal valid session name is accepted (not rejected as invalid)" { + # Every character class the allowlist permits, in one name. + S="loT3-worker_9" + mk_session + run sh "$SP" state "$S" + [ "$status" -eq 0 ] + [ "$output" = "ready" ] +} + +# ---------------------------------------------------------------- send + +@test "send: an idle session accepts the prompt and actually runs it" { + mk_session + run --separate-stderr sh "$SP" send "$S" 'echo T3_HELLO' + [ "$status" -eq 0 ] + [ "$output" = "delivered" ] + sleep 1 + # "delivered" must mean the pane really acted, not just that tmux returned 0. + tmux capture-pane -t "=$S:" -p | grep -q '^T3_HELLO$' +} + +@test "send: a prompt sent to a busy pane is reported queued, not delivered" { + # The bug this script exists to remove: send-keys returns 0 while the worker + # has only QUEUED the prompt. Measured in the field as a 55s invisible delay. + mk_session + mk_busy 8 + run --separate-stderr env LO_QUEUED_PATTERN=BUSYMARK_T3 sh "$SP" send "$S" 'echo T3_SECOND' + [ "$status" -eq 4 ] + [ "$output" = "queued" ] +} + +@test "send: negative control — the identical send to an idle pane is delivered" { + # Same command, same marker, only the pane's busyness differs. Without this, + # the queued test could pass for the wrong reason. + mk_session + run --separate-stderr env LO_QUEUED_PATTERN=BUSYMARK_T3 sh "$SP" send "$S" 'echo T3_SECOND' + [ "$status" -eq 0 ] + [ "$output" = "delivered" ] +} + +@test "send: a vanished session is reported gone, not delivered" { + run --separate-stderr sh "$SP" send "$S" 'echo T3_NOPE' + [ "$status" -eq 3 ] + [ "$output" = "gone" ] +} + +@test "send: a pane that shows no reaction is unconfirmed, never a false delivered" { + # Terminal echo off while the pane is busy: the keys go nowhere observable. + # "delivered" must be a positive finding, not the default when nothing is known. + mk_session + tmux send-keys -t "$S" -l -- "stty -echo; sleep 8; stty echo" + tmux send-keys -t "$S" Enter + sleep 1 + run --separate-stderr sh "$SP" send "$S" 'echo T3_INVISIBLE' + [ "$status" -eq 7 ] + [ "$output" = "unconfirmed" ] +} + +@test "send: usage error when the prompt argument is missing" { + run sh "$SP" send "$S" + [ "$status" -eq 1 ] +} + +# ---------------------------------------------------------------- wait + +@test "wait: returns picked-up once the worker drains its queue" { + mk_session + mk_busy 3 + run --separate-stderr env LO_QUEUED_PATTERN=BUSYMARK_T3 sh "$SP" wait "$S" 15 + [ "$status" -eq 0 ] + [ "$output" = "picked-up" ] +} + +@test "wait: a deadline that passes with the prompt still queued exits 5" { + # The coordinator must be able to tell "still working" from "never received + # it". Same setup as the picked-up case; only the deadline differs. + mk_session + mk_busy 30 + run --separate-stderr env LO_QUEUED_PATTERN=BUSYMARK_T3 sh "$SP" wait "$S" 4 + [ "$status" -eq 5 ] + [ "$output" = "timeout" ] +} + +@test "wait: a session that disappears is reported gone, not timeout" { + run --separate-stderr sh "$SP" wait "$S" 4 + [ "$status" -eq 3 ] + [ "$output" = "gone" ] +} + +@test "wait: an idle session returns immediately" { + mk_session + run --separate-stderr env LO_QUEUED_PATTERN=BUSYMARK_T3 sh "$SP" wait "$S" 10 + [ "$status" -eq 0 ] + [ "$output" = "picked-up" ] +} + +@test "boundary: a deadline shorter than one poll interval still polls once" { + # iters = timeout / interval floors to 0 here; it must floor UP to 1 so an + # idle worker is never reported as a false timeout. + mk_session + run --separate-stderr env LO_PICKUP_INTERVAL=5 LO_QUEUED_PATTERN=BUSYMARK_T3 \ + sh "$SP" wait "$S" 1 + [ "$status" -eq 0 ] + [ "$output" = "picked-up" ] +} + +@test "boundary: a zero deadline is a usage error, not an instant timeout" { + mk_session + run sh "$SP" wait "$S" 0 + [ "$status" -eq 1 ] +} + +@test "error: a non-numeric deadline is a usage error" { + mk_session + run sh "$SP" wait "$S" abc + [ "$status" -eq 1 ] +} + +@test "error: a negative deadline is a usage error" { + mk_session + run sh "$SP" wait "$S" -5 + [ "$status" -eq 1 ] +} + +@test "usage: wait without a session name exits 1" { + run sh "$SP" wait + [ "$status" -eq 1 ] +} + +# --------------------------------------------------- injection guard (prompt) + +@test "injection: a prompt starting with a dash is sent literally, not parsed as a tmux flag" { + # Without a '--' separator tmux rejects this with "unknown flag -n" and the + # prompt is silently never delivered. + mk_session + run --separate-stderr sh "$SP" send "$S" '-n not-a-flag' + [ "$status" -eq 0 ] + [ "$output" = "delivered" ] + tmux capture-pane -t "=$S:" -p | grep -q -- '-n not-a-flag' +} + +@test "injection: send-prompt.sh never evaluates the prompt it carries" { + # The payload is a shell COMMENT, so the pane's own shell will not run it. + # That isolates the actor under test: if the marker file appears, it can only + # be because send-prompt.sh expanded the string itself. + mk_session + pwned="${BATS_TEST_TMPDIR}/t3-pwned" + run --separate-stderr sh "$SP" send "$S" "# T3_SAFE \$(touch $pwned) \`touch $pwned\` && rm -rf ~" + [ "$status" -eq 0 ] + [ "$output" = "delivered" ] + [ ! -e "$pwned" ] + sleep 1 + # ...and it arrived verbatim: the metacharacters are still literal in the pane. + tmux capture-pane -t "=$S:" -p | grep -qF '$(touch' +} + +@test "injection: a newline in the prompt is rejected (it would submit a partial prompt)" { + mk_session + run --separate-stderr sh "$SP" send "$S" "$(printf 'first line\nrm -rf ~')" + [ "$status" -eq 2 ] + [ "$output" != "delivered" ] +} + +@test "injection: a carriage return in the prompt is rejected" { + mk_session + run --separate-stderr sh "$SP" send "$S" "$(printf 'first\rsecond')" + [ "$status" -eq 2 ] +} + +@test "boundary: an empty prompt is rejected" { + mk_session + run --separate-stderr sh "$SP" send "$S" '' + [ "$status" -eq 2 ] + [ "$output" != "delivered" ] +} + +@test "boundary: a multibyte (Korean) prompt survives the control-character filter" { + # The filter runs in the C locale, so it must reject only bytes 0x00-0x1F/0x7F + # and leave UTF-8 continuation bytes alone. Real briefs contain Korean. + mk_session + run --separate-stderr sh "$SP" send "$S" 'echo 한글_프롬프트_안전' + [ "$status" -eq 0 ] + [ "$output" = "delivered" ] +} + +@test "boundary: a long single-line prompt is accepted" { + mk_session + long=$(printf 'echo '; awk 'BEGIN{while(i++<600)printf "x"}') + run --separate-stderr sh "$SP" send "$S" "$long" + [ "$status" -eq 0 ] + [ "$output" = "delivered" ] +} + +# ---------------------------------------------------------------- template +# +# session-prompt.md carries invariants that nothing else in the repo checks: +# SKILL.md cites the section headings as substring anchors, the four tmux +# prompts are injected with `send-keys -l` and so MUST stay one line each, and +# the REQUIRED subagent block must survive verbatim. These are the first +# automated guards on that file. + +# 0 when all four tmux prompt bodies are exactly one non-empty line. +# Written as a function so it can be run against a deliberately broken copy — +# a gate nobody has ever seen fail is not yet a gate. +tpl_sections_single_line() { + f="$1" + for n in 1 2 3 4; do + ln=$(grep -n "^## ($n) " "$f" | cut -d: -f1) + [ -n "$ln" ] || return 1 + body=$((ln + 2)); after=$((ln + 3)) + [ -n "$(sed -n "${body}p" "$f")" ] || return 1 + [ -z "$(sed -n "${after}p" "$f")" ] || return 1 + done + return 0 +} + +@test "template: the four tmux prompts are each exactly one non-empty line" { + tpl_sections_single_line "$TPL" +} + +@test "template: negative control — the single-line guard fails on a wrapped prompt" { + # Proves the guard above can fail. Without this it would pass even if it were + # checking nothing at all. + broken="${BATS_TEST_TMPDIR}/wrapped.md" + cp "$TPL" "$broken" + ln=$(grep -n '^## (2) ' "$broken" | cut -d: -f1) + body=$((ln + 2)) + # Split section (2)'s single line into two. + awk -v b="$body" 'NR==b{sub(/ /, "\n"); print; next} {print}' "$broken" > "$broken.tmp" + mv "$broken.tmp" "$broken" + run tpl_sections_single_line "$broken" + [ "$status" -ne 0 ] +} + +@test "template: the REQUIRED subagent block is byte-identical" { + # Byte-level, not substring: a reword that keeps the keywords but changes the + # rule ("you MAY call the auditor") would slip past a grep and still break the + # protocol every worker depends on. cksum is POSIX, so this holds on both CI + # runners. If this block is ever intentionally changed, update this number in + # the SAME commit and say so in the PR. + run sh -c "sed -n '/^## Subagent usage protocol/,\$p' '$TPL' | cksum" + [ "$status" -eq 0 ] + [ "$output" = "2594177116 1010" ] +} + +@test "template: negative control — the byte-identity guard fails on a reworded block" { + # Proves the checksum above is actually comparing content, not just running. + reworded="${BATS_TEST_TMPDIR}/reworded.md" + sed 's/you MUST call the/you MAY call the/' "$TPL" > "$reworded" + ! cmp -s "$TPL" "$reworded" # the edit really changed something + run sh -c "sed -n '/^## Subagent usage protocol/,\$p' '$reworded' | cksum" + [ "$output" != "2594177116 1010" ] +} + +@test "template: the Orca prompt set is byte-identical (out of scope for this change)" { + run sh -c "sed -n '/^\*\*Orca substrate\.\*\*/,/^## Subagent usage protocol/p' '$TPL' | cksum" + [ "$status" -eq 0 ] + [ "$output" = "1714004932 4937" ] +} + +@test "template: the REQUIRED block still states its three rules" { + # Complements the checksum: names WHICH rules must be present, so a future + # intentional edit that drops one is obvious in the diff of this test. + grep -qF 'you MUST call the `test-quality-auditor` subagent via the Agent tool' "$TPL" + grep -qF 'you may delegate' "$TPL" + grep -qF 'do not call any agent by name other than `test-quality-auditor`' "$TPL" + grep -qF 'VERDICT: FAIL -> address REASONS by strengthening tests/code (NEVER weaken' "$TPL" +} + +@test "template: documents the real tmux delivery path" { + grep -qF 'send-prompt.sh send' "$TPL" + grep -qF 'send-prompt.sh wait' "$TPL" + grep -qF 'send-prompt.sh state' "$TPL" + # launch-session.sh must still be named as the owner of the FIRST injection, + # so nobody reimplements the trust-screen path in the delivery script. + grep -qF 'launch-session.sh' "$TPL" +} + +@test "template: the Orca prompts still carry their own reporting protocol" { + # §O1-§O4 are out of scope for this change; assert they were not disturbed. + for n in O1 O2 O3 O4; do + grep -qF "## ($n) " "$TPL" + done + # `--` before the pattern: without it grep parses the leading dashes as flags + # and dies "unrecognized option" — the same defect send-prompt.sh guards against. + grep -qF -- '--type worker_done' "$TPL" +} + +@test "template: SKILL.md's section anchors still resolve" { + # SKILL.md cites these as substring anchors; renaming a heading breaks it + # silently from the other file's side. + for n in 1 2 3 4; do + grep -qF "## ($n) " "$TPL" + done +} + +# ---------------------------------------------------------------- environment + +@test "error: a missing tmux is reported as 127, not a false verdict" { + run -127 --separate-stderr env PATH=/nonexistent /bin/sh "$SP" state "$S" + [ "$status" -eq 127 ] + # Assert the reason, not just the code: a missing *script* also exits 127, so + # a bare status check would pass vacuously. The diagnosis belongs on stderr. + case "$stderr" in *"tmux not found"*) : ;; *) return 1 ;; esac + [ -z "$output" ] +} diff --git a/tests/tmux-worker-stalled.bats b/tests/tmux-worker-stalled.bats new file mode 100644 index 0000000..6c1eb80 --- /dev/null +++ b/tests/tmux-worker-stalled.bats @@ -0,0 +1,235 @@ +#!/usr/bin/env bats +# Tests for tmux-worker-stalled.sh — the third worker state the alive/dead split +# cannot express, for the tmux substrate. Mirrors tests/orca-worker-stalled.bats. +# +# Driven by a tmux test double plus a pinned clock, so no live CLI is involved. +# Silence is accumulated ACROSS invocations (tmux exposes no "content last +# changed" timestamp), so most tests call the script twice: once to record the +# first observation, once with the clock advanced. +# +# The script is `#!/bin/sh`; invoke it with `sh`, not `bash`, or the shebang is +# overridden and a bashism would pass every test here while failing under dash +# in production. +# +# The executable stub lives under the repo's gitignored .claude/tmp — never +# $TMPDIR or /tmp, where creating and chmod +x-ing an executable is forbidden by +# policy. Non-executable fixture data uses $BATS_TEST_TMPDIR. + +setup() { + WS="${BATS_TEST_DIRNAME}/../skills/orchestrate/scripts/tmux-worker-stalled.sh" + SESS=lo-1 + SD="$BATS_TEST_TMPDIR/sd"; mkdir -p "$SD" + STATE="$BATS_TEST_TMPDIR/state"; mkdir -p "$STATE" + NOW=1700000000 +} + +REPO_TMP() { printf '%s' "${BATS_TEST_DIRNAME}/../.claude/tmp"; } + +mk_tmux_stub() { # -> path of an executable `tmux` test double + mkdir -p "$(REPO_TMP)" + local stub="$(REPO_TMP)/stall-stub-${BATS_TEST_NUMBER}" + cat > "$stub" <<'STUBEOF' +#!/bin/sh +# Test double for `tmux`. State under $STUB_DIR: +# pane text served by capture-pane (absent -> empty output) +# capture-rc exit code for capture-pane (absent -> 0) +# has-session-rc exit code for has-session (absent -> 0, "session exists") +# activity value served for #{window_activity} (absent -> 0) +set -u +d="${STUB_DIR:?}" +case "${1:-}" in + has-session) exit "$(cat "$d/has-session-rc" 2>/dev/null || echo 0)" ;; + capture-pane) + rc="$(cat "$d/capture-rc" 2>/dev/null || echo 0)" + [ "$rc" = "0" ] || exit "$rc" + [ -f "$d/pane" ] && cat "$d/pane" + exit 0 ;; + display-message) cat "$d/activity" 2>/dev/null || echo 0; exit 0 ;; +esac +exit 0 +STUBEOF + chmod +x "$stub" + printf '%s' "$stub" +} + +teardown() { rm -f "$(REPO_TMP)/stall-stub-${BATS_TEST_NUMBER}"; } + +# run the detector once, with the clock pinned to $1 +probe() { # $1 = now (epoch seconds); rest = extra env assignments + local now="$1"; shift + run env STUB_DIR="$SD" LO_STALL_TMUX="$STUB" LO_STALL_STATE_DIR="$STATE" \ + LO_STALL_NOW="$now" "$@" sh "$WS" "$SESS" +} + +working_pane() { printf '%s\n' "✻ Thinking… (${1:-3}s · ↑ 1.2k tokens)" "❯ "; } +wedged_pane() { printf '%s\n' "❯ [Pasted text #1 +1 lines]" " ⏵⏵ bypass permissions on"; } + +# --- normal + the control that proves the detector discriminates ------------- + +@test "a pane unchanged past the threshold is stalled (exit 1) and names the session" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW"; [ "$status" -eq 0 ] # first observation + probe "$((NOW + 900))" + [ "$status" -eq 1 ] + [[ "$output" == *"[stalled]"* ]] + [[ "$output" == *"$SESS"* ]] + [[ "$output" == *"900s"* ]] +} + +@test "CONTROL: same clock, same state — a pane that CHANGED is progressing (exit 0)" { + # The opposite-verdict run. Without it, a detector that always returned 1 + # would pass the test above; this is what proves it discriminates. + STUB="$(mk_tmux_stub)"; working_pane 3 > "$SD/pane" + probe "$NOW"; [ "$status" -eq 0 ] + working_pane 903 > "$SD/pane" # the elapsed counter ticked + probe "$((NOW + 900))" + [ "$status" -eq 0 ] +} + +@test "REJECTED CANDIDATE: a fresh window_activity does not rescue an unchanged pane" { + # Measured 2026-08-05: #{window_activity} tracked `now` identically for a pane + # redrawing the SAME bytes and for a working pane, exactly as Orca's + # lastOutputAt did. This test fails if the signal is ever reimplemented on it. + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW"; [ "$status" -eq 0 ] + echo "$((NOW + 900))" > "$SD/activity" # output "0 seconds ago" + probe "$((NOW + 900))" + [ "$status" -eq 1 ] +} + +@test "a worker busy in Stop hooks is progressing, not stalled (hook latency)" { + # Measured 2026-08-05: a worker running its Stop-hook chain renders + # "running stop hooks… 6/7 · 1m 36s" with a ticking counter, so the pane keeps + # changing. Hook latency must never read as a stall. + STUB="$(mk_tmux_stub)" + printf '%s\n' "✢ Finagling… (running stop hooks… 6/7 · 1m 36s · ↓ 629 tokens)" "❯ " > "$SD/pane" + probe "$NOW"; [ "$status" -eq 0 ] + printf '%s\n' "✢ Finagling… (running stop hooks… 7/7 · 1m 55s · ↓ 629 tokens)" "❯ " > "$SD/pane" + probe "$((NOW + 900))" + [ "$status" -eq 0 ] +} + +# --- boundaries ------------------------------------------------------------- + +@test "exactly at the threshold counts as stalled (boundary)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW"; [ "$status" -eq 0 ] + probe "$((NOW + 600))" + [ "$status" -eq 1 ] +} + +@test "one second under the threshold is not stalled (boundary)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW"; [ "$status" -eq 0 ] + probe "$((NOW + 599))" + [ "$status" -eq 0 ] +} + +@test "the first observation is never a stall, and records state (boundary)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW" + [ "$status" -eq 0 ] + [ -f "$STATE/$SESS.stall" ] +} + +@test "LO_STALL_SEC overrides the threshold" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW" LO_STALL_SEC=30; [ "$status" -eq 0 ] + probe "$((NOW + 30))" LO_STALL_SEC=30 + [ "$status" -eq 1 ] +} + +@test "a small backwards clock step is precision, not skew — still progressing (boundary)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW"; [ "$status" -eq 0 ] + probe "$((NOW - 3))" + [ "$status" -eq 0 ] +} + +# --- unknown: every one of these must be 2, never 1 ------------------------- +# Killing a working agent is the damaging direction, so "cannot tell" is never +# allowed to read as "stalled". + +@test "a large backwards clock step is real skew, not a stall (exit 2)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW"; [ "$status" -eq 0 ] + probe "$((NOW - 60))" + [ "$status" -eq 2 ] +} + +@test "a missing session argument is a usage error (exit 2)" { + STUB="$(mk_tmux_stub)" + run env STUB_DIR="$SD" LO_STALL_TMUX="$STUB" LO_STALL_STATE_DIR="$STATE" sh "$WS" + [ "$status" -eq 2 ] + [[ "$output" == *"usage"* ]] +} + +@test "a non-numeric threshold is refused rather than silently defaulted (exit 2)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW" LO_STALL_SEC=abc + [ "$status" -eq 2 ] +} + +@test "a zero threshold is refused (boundary — it would flag every worker)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + probe "$NOW" LO_STALL_SEC=0 + [ "$status" -eq 2 ] +} + +@test "an unresolvable tmux binary is unknown, never stalled (exit 2)" { + STUB=/nonexistent/tmux; wedged_pane > "$SD/pane" + probe "$NOW" + [ "$status" -eq 2 ] +} + +@test "a failing capture-pane is unknown, not stalled (exit 2)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane"; echo 1 > "$SD/capture-rc" + probe "$NOW" + [ "$status" -eq 2 ] +} + +@test "an empty pane capture is unknown, not stalled (boundary, exit 2)" { + STUB="$(mk_tmux_stub)" # no pane file -> capture-pane prints nothing + probe "$NOW" + [ "$status" -eq 2 ] +} + +@test "a malformed state file is unknown, and self-heals for the next poll (exit 2)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + printf 'garbage-with-no-timestamp\n' > "$STATE/$SESS.stall" + probe "$NOW" + [ "$status" -eq 2 ] + # rewritten as a fresh observation, so the NEXT poll can decide + probe "$((NOW + 900))" + [ "$status" -eq 1 ] +} + +@test "a state file with a non-numeric timestamp is unknown, not stalled (exit 2)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + printf 'deadbeef notanumber\n' > "$STATE/$SESS.stall" + probe "$NOW" + [ "$status" -eq 2 ] +} + +@test "an unwritable state dir is unknown, not stalled (exit 2)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + STATE="$BATS_TEST_TMPDIR/ro"; mkdir -p "$STATE"; chmod 500 "$STATE" + probe "$NOW" + chmod 700 "$STATE" + [ "$status" -eq 2 ] +} + +@test "a session name that could escape the state dir is refused (exit 2)" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane" + run env STUB_DIR="$SD" LO_STALL_TMUX="$STUB" LO_STALL_STATE_DIR="$STATE" \ + LO_STALL_NOW="$NOW" sh "$WS" "../../etc/passwd" + [ "$status" -eq 2 ] +} + +# --- the alive/dead question belongs to watch-status.sh, not here ----------- + +@test "a session that is gone is NOT reported as a stall — that is watch-status.sh's question" { + STUB="$(mk_tmux_stub)"; wedged_pane > "$SD/pane"; echo 1 > "$SD/has-session-rc" + probe "$NOW" + [ "$status" -eq 0 ] +} diff --git a/tests/watch-status.bats b/tests/watch-status.bats index 64a03d1..95c5184 100644 --- a/tests/watch-status.bats +++ b/tests/watch-status.bats @@ -61,3 +61,97 @@ setup() { run env WATCH_TMUX=/nonexistent/tmux bash "$WS" "$ORCH/status" implementing 1 3 1 [ "$status" -eq 0 ] } + +# ---- per-phase deadlines (LO_PHASE_TIMEOUTS) -------------------------------- +# One flat timeout gave a plan phase and a long implement phase the same budget. +# The budget is now keyed on the TARGET phase of this wait, and every run states +# which budget it is using and where that budget came from. +# +# These invoke the script with `sh`, not `bash`: it is `#!/bin/sh`, and `bash` +# would override the shebang and let a bashism pass here but fail under dash. +# (The eight tests above predate this and are left exactly as they were — note +# they all pass the 4th positional argument, which still wins.) + +@test "per-phase: the target phase's entry supplies the budget" { + printf '{"task":"t1","phase":"plan_ready"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="plan_ready=900,impl_done=3600" \ + sh "$WS" "$ORCH/status" plan_ready 1 + [ "$status" -eq 0 ] + [[ "$output" == *"budget=900s target=plan_ready source=LO_PHASE_TIMEOUTS"* ]] +} + +@test "per-phase: the SAME env string yields a different budget for a different target" { + printf '{"task":"t1","phase":"impl_done"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="plan_ready=900,impl_done=3600" \ + sh "$WS" "$ORCH/status" impl_done 1 + [ "$status" -eq 0 ] + [[ "$output" == *"budget=3600s target=impl_done source=LO_PHASE_TIMEOUTS"* ]] +} + +@test "per-phase: with no env at all the default budget is used and named" { + printf '{"task":"t1","phase":"done"}' > "$ORCH/status/t1.json" + run sh "$WS" "$ORCH/status" done 1 + [ "$status" -eq 0 ] + [[ "$output" == *"budget=3600s target=done source=default"* ]] +} + +@test "per-phase: an explicitly passed timeout argument wins over the env" { + printf '{"task":"t1","phase":"done"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="done=900" sh "$WS" "$ORCH/status" done 1 42 1 + [ "$status" -eq 0 ] + [[ "$output" == *"budget=42s target=done source=arg"* ]] +} + +@test "per-phase: a target absent from the env falls back to the default (boundary)" { + printf '{"task":"t1","phase":"done"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="plan_ready=900" sh "$WS" "$ORCH/status" done 1 + [ "$status" -eq 0 ] + [[ "$output" == *"budget=3600s target=done source=default"* ]] +} + +@test "per-phase: an empty env value is treated as unset, not as an error (boundary)" { + printf '{"task":"t1","phase":"done"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="" sh "$WS" "$ORCH/status" done 1 + [ "$status" -eq 0 ] + [[ "$output" == *"source=default"* ]] +} + +@test "per-phase: a trailing comma is tolerated (boundary)" { + printf '{"task":"t1","phase":"plan_ready"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="plan_ready=900," sh "$WS" "$ORCH/status" plan_ready 1 + [ "$status" -eq 0 ] + [[ "$output" == *"budget=900s"* ]] +} + +@test "per-phase: the env budget actually bounds the wait, and the TIMEOUT line names it" { + printf '{"task":"t1","phase":"pending"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="done=1" sh "$WS" "$ORCH/status" done 1 + [ "$status" -eq 2 ] + [[ "$output" == *"TIMEOUT (1s, source=LO_PHASE_TIMEOUTS)"* ]] +} + +@test "per-phase: a non-numeric budget is refused, never silently defaulted (error)" { + printf '{"task":"t1","phase":"done"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="plan_ready=abc" sh "$WS" "$ORCH/status" done 1 + [ "$status" -eq 4 ] + [[ "$output" == *"plan_ready=abc"* ]] +} + +@test "per-phase: a typo'd phase name is refused, not silently ignored (error)" { + printf '{"task":"t1","phase":"done"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="notaphase=900" sh "$WS" "$ORCH/status" done 1 + [ "$status" -eq 4 ] + [[ "$output" == *"notaphase"* ]] +} + +@test "per-phase: a zero budget is refused — it would time out instantly (boundary)" { + printf '{"task":"t1","phase":"done"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="done=0" sh "$WS" "$ORCH/status" done 1 + [ "$status" -eq 4 ] +} + +@test "per-phase: an entry with no '=' is refused (error)" { + printf '{"task":"t1","phase":"done"}' > "$ORCH/status/t1.json" + run env LO_PHASE_TIMEOUTS="plan_ready" sh "$WS" "$ORCH/status" done 1 + [ "$status" -eq 4 ] +} diff --git a/tests/worker-guardrails.bats b/tests/worker-guardrails.bats index 61e3a7d..76e41e1 100644 --- a/tests/worker-guardrails.bats +++ b/tests/worker-guardrails.bats @@ -61,6 +61,51 @@ setup() { [ ! -e "${BATS_TEST_TMPDIR}/nope" ] } +# The three tests below split the config into two named properties, because one +# combined assertion cannot say which one broke: COVERAGE (every rule id is still +# there) and VALIDITY (each rule's mode is still the one it shipped with), plus the +# new allowPaths declaration. Coverage asserts the rule COUNT before comparing the +# id set — a config that parsed to zero rules would otherwise satisfy any set +# comparison and pass vacuously. +@test "worktree_escape declares .orchestration as an allowed path" { + sh "$WG" "$wt" + cfg="$wt/.groundwork/guardrails.json" + run jq -r '.rules.worktree_escape.allowPaths[0] // "MISSING"' "$cfg" + [ "$output" = ".orchestration" ] + # boundary: exactly one sanctioned path — this declares a path, it does not + # widen the rule into an allow-everything list. + run jq -r '.rules.worktree_escape.allowPaths | length' "$cfg" + [ "$output" -eq 1 ] + # the rule itself must stay `ask`: protection is declared-around, not disabled. + run jq -r '.rules.worktree_escape.mode' "$cfg" + [ "$output" = "ask" ] +} + +@test "coverage: the emitted config still holds exactly the nine known rule ids" { + sh "$WG" "$wt" + cfg="$wt/.groundwork/guardrails.json" + run jq -r '.rules | length' "$cfg" + [ "$status" -eq 0 ] + [ "$output" -eq 9 ] + run jq -r '.rules | keys_unsorted | join(",")' "$cfg" + [ "$output" = "rm_rf,git_discard,system_tmp_write,cloud_delete,sql_drop,git_force_push,secret_export,curl_pipe_shell,worktree_escape" ] +} + +@test "validity: every pre-existing rule keeps its mode, and only worktree_escape gains a key" { + sh "$WG" "$wt" + cfg="$wt/.groundwork/guardrails.json" + for pair in rm_rf:off git_discard:off system_tmp_write:off cloud_delete:ask \ + sql_drop:ask git_force_push:ask secret_export:ask \ + curl_pipe_shell:ask worktree_escape:ask; do + id="${pair%%:*}"; want="${pair##*:}" + run jq -r --arg r "$id" '.rules[$r].mode // "MISSING"' "$cfg" + [ "$output" = "$want" ] + done + # no other rule was handed an allowPaths while we were in there + run jq -r '[.rules | to_entries[] | select(.value.allowPaths) | .key] | join(",")' "$cfg" + [ "$output" = "worktree_escape" ] +} + @test "boundary: a non-git directory still gets the config (exclude simply skipped)" { plain="${BATS_TEST_TMPDIR}/plain"; mkdir -p "$plain" run sh "$WG" "$plain"