From e4953e2e9dc57a38c929ba846e301b0b0efcd1ff Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 10:50:37 -0700 Subject: [PATCH 01/19] feat(bin): add fm-live-lab.sh, a one-command live supervision lab builder --- bin/fm-claude-trust.sh | 47 ++- bin/fm-live-lab.sh | 633 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 677 insertions(+), 3 deletions(-) create mode 100755 bin/fm-live-lab.sh diff --git a/bin/fm-claude-trust.sh b/bin/fm-claude-trust.sh index 6e1a49a9776..abd26e6b034 100755 --- a/bin/fm-claude-trust.sh +++ b/bin/fm-claude-trust.sh @@ -10,10 +10,13 @@ # # Usage: fm-claude-trust.sh # fm-claude-trust.sh --secondmate-home +# fm-claude-trust.sh --lab-home # the isolated task worktree this spawn launches into # the primary checkout that worktree belongs to # the seeded secondmate home this spawn launches into # the secondmate id that home must already be marked for +# --lab-home the disposable lab home bin/fm-live-lab.sh launches a lab +# primary in # Prints one line naming what it registered; refuses loudly on anything else. # # WHY THIS EXISTS. Claude Code gates a folder it has never seen behind an @@ -144,13 +147,23 @@ # argument to gate external-imports consent against, so the two import flags # are never written there. # +# LAB-HOME MODE. A disposable lab primary (bin/fm-live-lab.sh) launches in a lab +# home that is neither a task worktree nor a seeded secondmate home, and its +# trust dialog was a recorded lab miss. The evidence is again structural: the +# home must carry bin/fm-lab-home.sh's marker (a regular file this user owns, +# never a symlink, holding the token bin/fm-gate-refuse-lib.sh owns), hold +# AGENTS.md and bin/, and be a primary git checkout whose top level is exactly +# the argument, because Claude Code keys the launch to that root. It is +# trust-only for the same reason as a secondmate home, and bin/fm-live-lab.sh +# removes the entry again when it tears the lab down. +# # Only the launching user's own store is written. In worktree mode: the # projects entries for the worktree path and the resolved canonical project # path in ${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json, which must be a regular # file this uid owns; every unrelated key and project entry is preserved, and -# both entries land in one atomic replacement. In secondmate-home mode: the -# single projects entry for the registered home path, same store, same atomic -# replacement. fm-spawn.sh forwards CLAUDE_CONFIG_DIR onto the claude launch +# both entries land in one atomic replacement. In secondmate-home and lab-home +# mode: the single projects entry for the registered home path, same store, +# same atomic replacement. fm-spawn.sh forwards CLAUDE_CONFIG_DIR onto the claude launch # verbatim rather than resolving it, and the pane starts in the registered # directory, so only an absolute value names the same store on both sides; a # relative one is refused below rather than guessed at. @@ -174,6 +187,7 @@ unset CDPATH \ usage() { echo "usage: fm-claude-trust.sh " >&2 echo " fm-claude-trust.sh --secondmate-home " >&2 + echo " fm-claude-trust.sh --lab-home " >&2 exit 2 } @@ -189,6 +203,14 @@ case "${1:-}" in PROJ_ARG= SCOPE_NOUN="secondmate home" ;; + --lab-home) + [ "$#" -eq 2 ] || usage + MODE=lab-home + TARGET_ARG=$2 + SUB_ID= + PROJ_ARG= + SCOPE_NOUN="lab home" + ;; '' | -h | --help) usage ;; @@ -204,6 +226,9 @@ esac refuse() { echo "error: refusing to pre-register Claude trust: $1" >&2; exit 1; } +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/fm-gate-refuse-lib.sh" + real_dir() { (cd -P -- "$1" 2>/dev/null && pwd -P); } # The fully resolved path of an existing file, or empty. Resolution runs in node @@ -303,6 +328,22 @@ if [ "$MODE" = worktree ]; then [ -n "$CANON_GIT_DIR" ] && [ "$CANON_GIT_DIR" = "$PROJ_COMMON" ] \ || refuse "project '$PROJ_REAL' is a linked worktree whose primary checkout could not be resolved" fi +elif [ "$MODE" = lab-home ]; then + LAB_MARKER="$TARGET_REAL/$FM_GATE_LAB_MARKER" + [ ! -L "$LAB_MARKER" ] || refuse "'$LAB_MARKER' is a symlink; a lab home carries the marker as a regular file" + if ! { [ -f "$LAB_MARKER" ] && [ -O "$LAB_MARKER" ] && fm_gate_lab_home "$TARGET_REAL"; }; then + refuse "'$TARGET_REAL' carries no lab-home marker owned by this user, so it is not a disposable lab home" + fi + [ -f "$TARGET_REAL/AGENTS.md" ] || refuse "'$TARGET_REAL' has no AGENTS.md, so it is not a firstmate home" + [ -d "$TARGET_REAL/bin" ] || refuse "'$TARGET_REAL' has no bin/, so it is not a firstmate home" + LAB_TOP=$(git -C "$TARGET_REAL" rev-parse --show-toplevel 2>/dev/null) || true + [ -n "$LAB_TOP" ] && [ "$(real_dir "$LAB_TOP")" = "$TARGET_REAL" ] \ + || refuse "'$TARGET_REAL' is not the top level of a git checkout" + LAB_GIT_DIR=$(git -C "$TARGET_REAL" rev-parse --absolute-git-dir 2>/dev/null) || true + LAB_GIT_DIR=$(real_dir "${LAB_GIT_DIR:-}") || true + LAB_COMMON=$(common_dir_of "$TARGET_REAL") || true + [ -n "$LAB_GIT_DIR" ] && [ "$LAB_GIT_DIR" = "$LAB_COMMON" ] \ + || refuse "'$TARGET_REAL' is a linked worktree, not the primary checkout a lab primary launches in" else # The seed evidence, in the order that names the most useful reason first: the # marker decides whether this is a secondmate home at all, the id decides diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh new file mode 100755 index 00000000000..4730628baa6 --- /dev/null +++ b/bin/fm-live-lab.sh @@ -0,0 +1,633 @@ +#!/usr/bin/env bash +# fm-live-lab.sh - stand up, check, drive, and tear down one disposable live +# supervision lab: a real lab main session on Claude or Pi, with the +# supervision host (Claude) or branch (Pi) wired as a real home runs it, +# optionally a real seeded local second mate and a real gated worker. +# +# Usage: +# fm-live-lab.sh up --harness claude|pi [--mate] [--worker] +# [--model ] [--effort ] +# [--supervision-host |none] [--expect-host yes|no] +# [--source ] [--ref ] [--env NAME=VALUE]... +# [--timeout ] [] +# fm-live-lab.sh check +# fm-live-lab.sh say [--window ] +# fm-live-lab.sh pane [--window ] [--lines ] +# fm-live-lab.sh down +# +# up builds everything under (a fresh path; default a new +# /tmp/fmlab.XXXXXX), verifies readiness itself, and prints one line per check. +# It exits 0 only when every check passed; otherwise it exits 1 and leaves the +# lab up for inspection, so run down either way. check re-runs the same checks +# once. say types text into a lab window and presses Enter (window main, the +# lab primary, by default; mate and lab-worker are the other windows). pane +# prints a window's recent scrollback. down stops every lab process, removes the +# lab's Claude trust entries by one atomic replace, removes , and exits +# non-zero if anything outside the lab changed. +# +# What up builds, and the recorded lab miss each part closes: +# home/ the lab main home: bin/fm-lab-home.sh create, then the +# committed tree of (default: HEAD of the +# checkout this script runs from) checked out as a genuine +# primary checkout, with FM_HOME at its root. A primary +# launched from a linked worktree or from an operational home +# that is not the tree's root had no dialog mirror. +# config/ backend tmux, Claude crews and second mates, and +# supervision-host (default claude on Claude, absent +# on Pi; none leaves the file absent). +# tmux server private, through the lab home's bin/fm-lab-home.sh +# tmux-dir, started from an empty environment so no inherited +# TMUX, Herdr, or Pi marker reaches a lab process (a probe +# that inherited the caller's Pi marker was discarded). +# TREEHOUSE_ROOT points into , so a worker's pool +# never lands in ~/.treehouse. +# trust Claude: bin/fm-claude-trust.sh --lab-home for the primary +# (the workspace-trust prompt wedged the first lab), and the +# spawn's own registration for the mate and worker. Pi: +# --approve, which trusts project-local files for this run +# only, so the Pi trust store is never written and all of +# .pi/extensions loads (one Pi lab lacked the branch +# extension); sessions stay under /pi-sessions. +# mate/ --mate: bin/fm-home-seed.sh mate /mate +# --no-projects (an explicit path cloned from the git lab +# home), launched by bin/fm-spawn.sh --secondmate. +# lab-worker --worker: a lab project notes with a lab-private origin +# (local-only +yolo), a scaffolded brief, a backlog item, and +# a real bin/fm-spawn.sh worker that parks on +# /gates/lab-worker until that file exists; touch +# it to let the worker finish. +# primary window main: claude --setting-sources project,local +# (default sonnet, medium, permission mode auto) or pi +# (default openai-codex/gpt-6-luna, medium; three older +# Codex models were rejected by the account), launched +# after the mate and worker so its first turn end arms +# supervision. up then sends one harmless probe prompt. +# +# Readiness checks (check prints "ok : ..." or "fail : ..."): +# primary window main is alive, in the lab home, which is a primary +# checkout, and the lab session lock names a live process. +# probe the primary answered the probe with its nonce (the model is +# accepted and a whole turn ran). +# trust Claude: the lab home carries registered trust. +# Pi: the Pi trust store is byte-identical to before up. +# mirror Claude with --expect-host yes: the tree's +# fm-host-mirror.sh verified claude and fm-host-mirror.sh check +# pass, and the mirror holds a captain and a main entry (the +# feed was once not wired, and once the check existed only on +# an unmerged branch; a missing subcommand fails here by name). +# extensions Pi: the watcher, turn-end guard, and branch extensions are +# loaded by the process holding the lab session lock, at the +# current on-disk builds. +# host Claude: with --expect-host yes (the default on Claude) the +# supervision host runs; with no, none runs. Skipped when the +# lab has no mate or worker, since an empty fleet arms nothing. +# watcher a live watcher with a fresh beacon holds this home's lock +# (skipped on an empty fleet). +# mate --mate: its window is alive and its own session lock names a +# live process, so it got past trust into its charter. +# worker --worker: the worker declared its gate wait in its status. +# treehouse ~/.treehouse gained no entry since up began. +# +# down refuses any path without the lab record up writes, kills only the lab's +# private tmux server and processes whose command line names , runs +# bin/fm-lab-home.sh teardown, removes every ~/.claude.json project entry at or +# under (compare-and-swap atomic replace, unrelated entries kept), +# reports a changed Pi trust store or new ~/.treehouse entry without touching +# either, and removes . Transcripts under ~/.claude/projects are left +# as history. The lab never uses Herdr. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +BUILDER_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +LAB_HOME_HELPER="$SCRIPT_DIR/fm-lab-home.sh" +CLAUDE_TRUST="$SCRIPT_DIR/fm-claude-trust.sh" +RECORD_NAME=.fm-live-lab +RECORD_TOKEN='fm-live-lab v1' +PI_TRUST_STORE="$HOME/.pi/agent/trust.json" +TREEHOUSE_DIR="$HOME/.treehouse" + +die() { echo "fm-live-lab: $*" >&2; exit 1; } +help_text() { sed -n '/^# Usage:/,/^# up builds/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; } +usage() { help_text >&2; exit 2; } + +real_dir() { (cd -P -- "$1" 2>/dev/null && pwd -P); } + +digest() { # -> sha256 of its bytes, or "absent" + [ -f "$1" ] || { echo absent; return; } + shasum -a 256 "$1" | awk '{print $1}' +} + +treehouse_listing() { ls -1A "$TREEHOUSE_DIR" 2>/dev/null || true; } + +rec_get() { # + sed -n "s/^$2=//p" "$1/$RECORD_NAME" | head -n 1 +} + +load_lab() { # : refuse anything up did not build, then load its record + local root=$1 + [ -n "$root" ] || usage + ROOT=$(real_dir "$root") || die "no lab at '$root'" + [ -f "$ROOT/$RECORD_NAME" ] && [ ! -L "$ROOT/$RECORD_NAME" ] && [ -O "$ROOT/$RECORD_NAME" ] \ + && [ "$(sed -n 1p "$ROOT/$RECORD_NAME")" = "$RECORD_TOKEN" ] \ + || die "refusing '$ROOT': it carries no lab record written by fm-live-lab.sh up" + HARNESS=$(rec_get "$ROOT" harness) + LAB=$(rec_get "$ROOT" home) + TMUX_DIR=$(rec_get "$ROOT" tmux_dir) + EXPECT_HOST=$(rec_get "$ROOT" expect_host) + WANT_MATE=$(rec_get "$ROOT" mate) + WANT_WORKER=$(rec_get "$ROOT" worker) + NONCE=$(rec_get "$ROOT" nonce) + PI_TRUST_BEFORE=$(rec_get "$ROOT" pi_trust) +} + +lab_tmux() { + [ -n "${TMUX_DIR:-}" ] || return 1 + env -u TMUX TMUX_TMPDIR="$TMUX_DIR" tmux "$@" +} + +# The empty-environment base every lab process starts from. +lab_env_base() { + printf '%s\n' "HOME=$HOME" "USER=${USER:-$(id -un)}" "LOGNAME=${USER:-$(id -un)}" \ + "PATH=$PATH" "SHELL=${SHELL:-/bin/zsh}" "TERM=xterm-256color" "LANG=${LANG:-en_US.UTF-8}" \ + "TMUX_TMPDIR=$TMUX_DIR" "TREEHOUSE_ROOT=$ROOT/treehouse" "FM_BACKEND=tmux" +} + +lab_run() { # [NAME=VALUE...] : run in the lab's clean environment + local -a base=() + local line + while IFS= read -r line; do base+=("$line"); done < <(lab_env_base) + env -i "${base[@]}" "$@" +} + +window_alive() { # + [ "$(lab_tmux display-message -p -t "firstmate:$1" '#{pane_dead}' 2>/dev/null)" = 0 ] +} + +pid_alive() { case "${1:-}" in ''|*[!0-9]*) return 1 ;; esac; kill -0 "$1" 2>/dev/null; } + +fleet_nonempty() { [ "$WANT_MATE" = yes ] || [ "$WANT_WORKER" = yes ]; } + +# ---- readiness checks ------------------------------------------------------- + +check_primary() { + local path gitdir common pid + window_alive main || { echo "fail primary: window main is not running"; return 1; } + path=$(lab_tmux display-message -p -t firstmate:main '#{pane_current_path}' 2>/dev/null) + [ "$(real_dir "$path")" = "$LAB" ] || { echo "fail primary: window main runs in '$path', not the lab home $LAB"; return 1; } + gitdir=$(real_dir "$(git -C "$LAB" rev-parse --absolute-git-dir 2>/dev/null)") + common=$(cd "$LAB" && real_dir "$(git rev-parse --git-common-dir 2>/dev/null)") + [ -n "$gitdir" ] && [ "$gitdir" = "$common" ] || { echo "fail primary: the lab home is not a primary checkout"; return 1; } + pid=$(sed -n 1p "$LAB/state/.lock" 2>/dev/null) + pid_alive "$pid" || { echo "fail primary: the lab session lock names no live process (session start has not run)"; return 1; } + echo "ok primary: $HARNESS pid $pid in $LAB" +} + +check_probe() { + if lab_tmux capture-pane -p -J -t firstmate:main -S -5000 2>/dev/null | grep -Fq "LABREADY-$NONCE"; then + echo "ok probe: the primary answered LABREADY-$NONCE" + else + echo "fail probe: no LABREADY-$NONCE reply in window main (model refused, turn still running, or a dialog is open)" + return 1 + fi +} + +check_trust() { + if [ "$HARNESS" = pi ]; then + [ "$(digest "$PI_TRUST_STORE")" = "$PI_TRUST_BEFORE" ] \ + || { echo "fail trust: the Pi trust store changed since up began"; return 1; } + echo "ok trust: Pi trust store unchanged (session-only --approve)" + return 0 + fi + if node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' \ + "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json" "$LAB" 2>/dev/null; then + echo "ok trust: $LAB is trusted in the Claude store" + else + echo "fail trust: $LAB has no registered Claude workspace trust" + return 1 + fi +} + +check_mirror() { + local out rc entries + out=$(cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-host-mirror.sh" verified claude 2>&1) + rc=$? + [ "$rc" -eq 0 ] || { echo "fail mirror: fm-host-mirror.sh verified claude exited $rc ${out:+($out)}"; return 1; } + out=$(cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-host-mirror.sh" check 2>&1) + rc=$? + [ "$rc" -eq 0 ] || { echo "fail mirror: fm-host-mirror.sh check exited $rc ${out:+($(printf '%s' "$out" | head -n 1))}"; return 1; } + entries=$(jq -rs '[.[].tag] | "captain=\(map(select(.=="captain"))|length) main=\(map(select(.=="main"))|length)"' \ + "$LAB/state/.host-mirror.jsonl" 2>/dev/null) + case "$entries" in + captain=0*|*main=0|'') echo "fail mirror: the dialog mirror has no captain and main entry yet (${entries:-no mirror file})"; return 1 ;; + esac + echo "ok mirror: verified writer, check passed, $entries" +} + +check_extensions() { + local pair source marker phase version out="" + for pair in fm-primary-pi-watch.ts:.pi-watch-extension-loaded:active fm-primary-turnend-guard.ts:.pi-turnend-extension-loaded; do + source=${pair%%:*}; marker=${pair#*:}; phase=${marker#*:}; marker=${marker%%:*} + [ "$phase" != "$marker" ] || phase= + # shellcheck disable=SC2016 # Expanded by the inner shell. + version=$(FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_pi_extension_version "$1/.pi/extensions/$2"' _ "$LAB" "$source" 2>/dev/null) + # shellcheck disable=SC2016 # Expanded by the inner shell. + FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_pi_extension_loaded "$1/state/$2" "$3" "$1/state/.lock" "$4"' \ + _ "$LAB" "$marker" "$version" "$phase" 2>/dev/null \ + || { echo "fail extensions: $source is not loaded at its current build by the lock holder"; return 1; } + out="$out ${source%.ts}" + done + [ "$(sed -n 1p "$LAB/state/.pi-branch-extension-loaded" 2>/dev/null)" = "$(sed -n 1p "$LAB/state/.lock" 2>/dev/null)" ] \ + || { echo "fail extensions: fm-branch-supervision.ts is not loaded by the lock holder"; return 1; } + echo "ok extensions:$out fm-branch-supervision" +} + +check_host() { + local pid + fleet_nonempty || { echo "ok host: skipped (empty fleet arms no supervision)"; return 0; } + pid=$(awk -F '\t' '$1=="host"{print $2; exit}' "$LAB/state/.supervision-host" 2>/dev/null) + if [ "$EXPECT_HOST" = yes ]; then + pid_alive "$pid" || { echo "fail host: no live supervision host (expected one)"; return 1; } + echo "ok host: supervision host pid $pid" + else + ! pid_alive "$pid" || { echo "fail host: supervision host pid $pid runs (expected none)"; return 1; } + echo "ok host: none running, as expected" + fi +} + +check_watcher() { + fleet_nonempty || { echo "ok watcher: skipped (empty fleet)"; return 0; } + # shellcheck disable=SC2016 # Expanded by the inner shell. + if FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_watcher_healthy "$1/state" "$1/bin/fm-watch.sh" 300 "$1"' _ "$LAB" 2>/dev/null; then + echo "ok watcher: live watcher with a fresh beacon" + else + echo "fail watcher: no live watcher with a fresh beacon holds the lab home" + return 1 + fi +} + +check_mate() { + local pid + window_alive mate || { echo "fail mate: window mate is not running"; return 1; } + pid=$(sed -n 1p "$ROOT/mate/state/.lock" 2>/dev/null) + pid_alive "$pid" || { echo "fail mate: the mate holds no session lock yet (wedged before its charter?)"; return 1; } + echo "ok mate: second mate pid $pid in $ROOT/mate" +} + +check_worker() { + window_alive lab-worker || { echo "fail worker: window lab-worker is not running"; return 1; } + grep -E '^paused' "$LAB/state/lab-worker.status" 2>/dev/null | grep -Fq "$ROOT/gates/lab-worker" \ + || { echo "fail worker: the worker has not declared its gate wait yet"; return 1; } + echo "ok worker: parked on $ROOT/gates/lab-worker" +} + +check_treehouse() { + local added + added=$(comm -13 "$ROOT/.treehouse-before" <(treehouse_listing | sort) 2>/dev/null) + [ -z "$added" ] || { echo "fail treehouse: new ~/.treehouse entries: $(printf '%s' "$added" | tr '\n' ' ')"; return 1; } + echo "ok treehouse: ~/.treehouse unchanged" +} + +run_checks() { + local rc=0 + check_primary || rc=1 + check_probe || rc=1 + check_trust || rc=1 + if [ "$HARNESS" = claude ]; then + if [ "$EXPECT_HOST" = yes ]; then check_mirror || rc=1; fi + check_host || rc=1 + else + check_extensions || rc=1 + fi + check_watcher || rc=1 + if [ "$WANT_MATE" = yes ]; then check_mate || rc=1; fi + if [ "$WANT_WORKER" = yes ]; then check_worker || rc=1; fi + check_treehouse || rc=1 + return "$rc" +} + +# ---- up --------------------------------------------------------------------- + +say_text() { # + lab_tmux send-keys -t "firstmate:$1" -l "$2" || return 1 + sleep 1 + lab_tmux send-keys -t "firstmate:$1" Enter +} + +make_notes_project() { + local seed="$ROOT/origins/notes-seed" origin="$ROOT/origins/notes.git" + mkdir -p "$seed/notes" "$seed/tests" + git init -q -b main "$seed" + cat > "$seed/notes/__init__.py" <<'PY' +"""A tiny notes library used by the firstmate live lab.""" + +NOTES = [] + + +def add_note(text, tags=None): + NOTES.append({"text": text, "tags": list(tags or [])}) + return len(NOTES) - 1 + + +def list_notes(): + return list(NOTES) +PY + cat > "$seed/tests/test_notes.py" <<'PY' +import unittest + +import notes + + +class NotesTest(unittest.TestCase): + def setUp(self): + notes.NOTES.clear() + + def test_add_and_list(self): + notes.add_note("hello", ["a"]) + self.assertEqual(notes.list_notes(), [{"text": "hello", "tags": ["a"]}]) + + +if __name__ == "__main__": + unittest.main() +PY + cat > "$seed/README.md" <<'MD' +# notes + +A tiny notes library for lab work. +Run the checks with `python3 -m unittest discover -s tests`. +MD + git -C "$seed" add -A + git -C "$seed" -c user.name=lab -c user.email=lab@example.invalid commit -q -m "seed notes" + git clone -q --bare "$seed" "$origin" + rm -rf "$seed" + git clone -q "$origin" "$LAB/projects/notes" + printf '# Projects\n\n- notes [local-only +yolo] - tiny lab notes library\n' > "$LAB/data/projects.md" +} + +spawn_worker() { + local brief="$LAB/data/lab-worker/brief.md" gate="$ROOT/gates/lab-worker" + mkdir -p "$ROOT/gates" + make_notes_project || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-brief.sh" lab-worker notes --mode local-only) >/dev/null || return 1 + TASK_TEXT="Lab gated worker for a live supervision lab. Add count_notes(), which returns how many notes are stored, to notes/__init__.py with a unit test, but only after the gate file $gate exists." \ + SPEC_TEXT="Right after setup, append one paused status line that names the gate file $gate, then check for that file every 15 seconds and do nothing else until it exists. Then implement count_notes() in notes/__init__.py and a test in tests/test_notes.py, run python3 -m unittest discover -s tests, commit, and report done. Nothing else is in scope." \ + python3 - "$brief" <<'PY' || return 1 +import os, sys +path = sys.argv[1] +text = open(path, encoding="utf-8").read() +text = text.replace("{TASK}", os.environ["TASK_TEXT"], 1).replace("{FIRSTMATE_SPEC}", os.environ["SPEC_TEXT"], 1) +open(path, "w", encoding="utf-8").write(text) +PY + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-tasks-axi.sh" add lab-worker "lab gated worker" --kind ship --repo notes) >/dev/null || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" lab-worker "$LAB/projects/notes" \ + --mode local-only --yolo on --harness claude --model sonnet --effort low) +} + +spawn_mate() { + local charter='Provide an idle live-validation lab second mate. When explicitly steered for a synthetic test, report only honest local lab outcomes; do not claim external PR activity, merge, or retire yourself.' + (cd "$LAB" && lab_run FM_HOME="$LAB" FM_SECONDMATE_CHARTER="$charter" \ + FM_SECONDMATE_SCOPE='second-mate live validation synthetic status relay' \ + "$LAB/bin/fm-home-seed.sh" mate "$ROOT/mate" --no-projects) || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" mate --secondmate) +} + +cmd_up() { + local harness="" mate=no worker=no model="" effort=medium host_line=__default__ expect_host="" source="$BUILDER_ROOT" ref=HEAD timeout=600 + local -a extra_env=() + local root="" + while [ "$#" -gt 0 ]; do + case "$1" in + --harness) harness=${2:-}; shift 2 ;; + --mate) mate=yes; shift ;; + --worker) worker=yes; shift ;; + --model) model=${2:-}; shift 2 ;; + --effort) effort=${2:-}; shift 2 ;; + --supervision-host) host_line=${2:-}; shift 2 ;; + --expect-host) expect_host=${2:-}; shift 2 ;; + --source) source=${2:-}; shift 2 ;; + --ref) ref=${2:-}; shift 2 ;; + --env) case "${2:-}" in [A-Za-z_]*=*) extra_env+=("$2") ;; *) die "--env takes NAME=VALUE" ;; esac; shift 2 ;; + --timeout) timeout=${2:-}; shift 2 ;; + -h|--help) help_text; exit 0 ;; + -*) die "unknown option '$1'" ;; + *) [ -z "$root" ] || usage; root=$1; shift ;; + esac + done + case "$harness" in claude|pi) ;; *) die "--harness must be claude or pi" ;; esac + case "$timeout" in ''|*[!0-9]*) die "--timeout takes seconds" ;; esac + [ -n "$expect_host" ] || { [ "$harness" = claude ] && expect_host=yes || expect_host=no; } + case "$expect_host" in yes|no) ;; *) die "--expect-host takes yes or no" ;; esac + [ "$host_line" != __default__ ] || { [ "$harness" = claude ] && host_line=claude || host_line=none; } + [ -n "$model" ] || { [ "$harness" = claude ] && model=sonnet || model=openai-codex/gpt-6-luna; } + for tool in git tmux jq node python3 shasum "$harness"; do + command -v "$tool" >/dev/null 2>&1 || die "$tool is required and was not found on PATH" + done + + if [ -z "$root" ]; then + root=$(mktemp -d /tmp/fmlab.XXXXXX) || die "cannot create a lab root" + else + [ ! -e "$root" ] || die "refusing '$root': a lab root must not exist yet" + mkdir -p "$root" || die "cannot create '$root'" + fi + ROOT=$(real_dir "$root") + LAB="$ROOT/home" + HARNESS=$harness EXPECT_HOST=$expect_host WANT_MATE=$mate WANT_WORKER=$worker + NONCE=$(od -An -N4 -tx4 /dev/urandom | tr -d ' \n') + PI_TRUST_BEFORE=$(digest "$PI_TRUST_STORE") + treehouse_listing | sort > "$ROOT/.treehouse-before" + { + echo "$RECORD_TOKEN" + echo "harness=$harness" + echo "home=$LAB" + echo "expect_host=$expect_host" + echo "mate=$mate" + echo "worker=$worker" + echo "nonce=$NONCE" + echo "pi_trust=$PI_TRUST_BEFORE" + } > "$ROOT/$RECORD_NAME" + echo "lab: $ROOT (tear down with: $0 down $ROOT)" + + "$LAB_HOME_HELPER" create "$LAB" >/dev/null || die "cannot create the lab home" + git -C "$LAB" init -q -b main || die "cannot initialize the lab home" + git -C "$LAB" fetch -q "$source" "$ref" || die "cannot fetch $ref from $source" + git -C "$LAB" checkout -q -f -B main FETCH_HEAD || die "cannot check out $ref" + git -C "$LAB" config user.name lab && git -C "$LAB" config user.email lab@example.invalid + mkdir -p "$LAB/state" "$LAB/data" "$LAB/config" "$LAB/projects" "$ROOT/treehouse" + printf 'tmux\n' > "$LAB/config/backend" + printf 'claude\n' > "$LAB/config/crew-harness" + printf 'claude sonnet low\n' > "$LAB/config/secondmate-harness" + printf 'auto\n' > "$LAB/config/claude-permission-mode" + [ "$host_line" = none ] || printf '%s\n' "$host_line" > "$LAB/config/supervision-host" + echo "tree: $(git -C "$LAB" rev-parse HEAD) from $source" + + TMUX_DIR=$("$LAB_HOME_HELPER" tmux-dir "$LAB") || die "cannot create the private tmux directory" + echo "tmux_dir=$TMUX_DIR" >> "$ROOT/$RECORD_NAME" + lab_run tmux new-session -d -s firstmate -n lab -x 220 -y 60 -c "$ROOT" || die "cannot start the lab tmux server" + + if [ "$mate" = yes ]; then spawn_mate || die "cannot seed and launch the second mate"; fi + if [ "$worker" = yes ]; then spawn_worker || die "cannot launch the gated worker"; fi + + local -a primary=() + if [ "$harness" = claude ]; then + lab_run "$CLAUDE_TRUST" --lab-home "$LAB" >/dev/null || die "cannot register Claude trust for the lab home" + primary=(claude --setting-sources "project,local" --model "$model" --effort "$effort" --permission-mode auto) + else + primary=(pi --approve --session-dir "$ROOT/pi-sessions" --model "$model" --thinking "$effort") + fi + lab_tmux new-window -d -t firstmate: -n main -c "$LAB" \ + env FM_HOME="$LAB" CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false ${extra_env[@]+"${extra_env[@]}"} "${primary[@]}" \ + || die "cannot launch the lab primary" + lab_tmux set-option -w -t firstmate:main remain-on-exit on >/dev/null + echo "primary: ${primary[*]}" + + local deadline=$(( $(date +%s) + 180 )) + until [ -f "$LAB/state/.session-start-complete" ] || [ "$(date +%s)" -ge "$deadline" ]; do sleep 2; done + sleep 5 + say_text main "Lab readiness probe from bin/fm-live-lab.sh. Run no tool or command for this message. Reply with only the word LABREADY, a hyphen, and then $NONCE, with no spaces." \ + || die "cannot send the readiness probe" + + deadline=$(( $(date +%s) + timeout )) + local report + while :; do + report=$(run_checks) && { printf '%s\n' "$report"; echo "ready: $ROOT"; return 0; } + [ "$(date +%s)" -lt "$deadline" ] || break + sleep 5 + done + printf '%s\n' "$report" + echo "not ready after ${timeout}s; the lab is left up for inspection: $0 pane $ROOT, then $0 down $ROOT" >&2 + return 1 +} + +# ---- down ------------------------------------------------------------------- + +ancestors() { + local pid=$$ + while pid_alive "$pid" && [ "$pid" -gt 1 ]; do + echo "$pid" + pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + done +} + +lab_pids() { # processes whose command line names the lab root, never this one's ancestry + local mine + mine=" $(ancestors | tr '\n' ' ') " + ps -axo pid=,command= | while read -r pid cmd; do + case "$cmd" in *"$ROOT"*|*"${ROOT#/private}"*) ;; *) continue ;; esac + case "$mine" in *" $pid "*) continue ;; esac + echo "$pid" + done +} + +forget_claude_entries() { # remove every project entry at or under ROOT; prints the count + local store="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json" + [ -f "$store" ] || { echo 0; return 0; } + node - "$store" "$ROOT" "${ROOT#/private}" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const [store, ...roots] = process.argv.slice(2); +const inLab = (key) => roots.some((r) => key === r || key.startsWith(`${r}/`)); +const fingerprint = (buf) => crypto.createHash("sha256").update(buf).digest("hex"); +for (let attempt = 0; attempt < 3; attempt += 1) { + const original = fs.readFileSync(store); + const root = JSON.parse(original.toString("utf8")); + const projects = root.projects; + if (projects === undefined) { console.log(0); process.exit(0); } + if (projects === null || typeof projects !== "object" || Array.isArray(projects)) { + console.error(`error: ${store} has a non-object "projects" value`); process.exit(1); + } + const removed = Object.keys(projects).filter(inLab); + if (removed.length === 0) { console.log(0); process.exit(0); } + const kept = Object.keys(projects).filter((k) => !inLab(k)); + for (const key of removed) delete projects[key]; + const tmp = path.join(path.dirname(store), `.claude.json.fm-live-lab.${process.pid}.${crypto.randomBytes(8).toString("hex")}`); + fs.writeFileSync(tmp, `${JSON.stringify(root, null, 2)}\n`, { mode: fs.statSync(store).mode & 0o777, flag: "wx" }); + let renamed = false; + try { + if (fingerprint(fs.readFileSync(store)) !== fingerprint(original)) continue; + fs.renameSync(tmp, store); + renamed = true; + } finally { + if (!renamed) fs.rmSync(tmp, { force: true }); + } + const back = Object.keys(JSON.parse(fs.readFileSync(store, "utf8")).projects || {}); + if (back.some(inLab) || kept.some((k) => !back.includes(k))) { + console.error(`error: ${store} did not keep exactly the non-lab entries`); process.exit(1); + } + console.log(removed.length); + process.exit(0); +} +console.error(`error: ${store} kept changing while lab entries were being removed`); +process.exit(1); +NODE +} + +cmd_down() { + load_lab "${1:-}" + local rc=0 pids n=0 removed added + lab_tmux kill-server 2>/dev/null || true + while pids=$(lab_pids) && [ -n "$pids" ] && [ "$n" -lt 20 ]; do + # shellcheck disable=SC2086 # One pid per word. + if [ "$n" -lt 10 ]; then kill $pids 2>/dev/null; else kill -9 $pids 2>/dev/null; fi + n=$((n + 1)); sleep 1 + done + pids=$(lab_pids) + [ -z "$pids" ] || die "refusing to remove the lab: processes still name it: $(printf '%s' "$pids" | tr '\n' ' ')" + echo "stopped: lab tmux server and lab processes" + if [ -f "$LAB/.fm-lab-home" ]; then + "$LAB_HOME_HELPER" teardown "$LAB" || die "cannot remove the private tmux directory" + fi + removed=$(forget_claude_entries) || die "cannot remove the lab's Claude trust entries" + echo "removed: $removed Claude project entries under $ROOT" + if [ "$(digest "$PI_TRUST_STORE")" != "$PI_TRUST_BEFORE" ]; then + echo "warning: the Pi trust store changed since up began; left as is" >&2 + rc=1 + fi + added=$(comm -13 "$ROOT/.treehouse-before" <(treehouse_listing | sort) 2>/dev/null) + if [ -n "$added" ]; then + echo "warning: ~/.treehouse gained entries during the lab; left as is: $(printf '%s' "$added" | tr '\n' ' ')" >&2 + rc=1 + fi + chmod -R u+w "$ROOT" 2>/dev/null + rm -rf "$ROOT" || die "cannot remove $ROOT" + [ ! -e "$ROOT" ] || die "$ROOT is still present" + echo "removed: $ROOT" + return "$rc" +} + +# ---- say / pane / check ----------------------------------------------------- + +cmd_say() { + local window=main + load_lab "${1:-}"; shift + [ "${1:-}" = --window ] && { window=${2:-}; shift 2; } + [ "$#" -ge 1 ] || usage + say_text "$window" "$*" +} + +cmd_pane() { + local window=main lines=200 + load_lab "${1:-}"; shift + while [ "$#" -gt 0 ]; do + case "$1" in + --window) window=${2:-}; shift 2 ;; + --lines) lines=${2:-}; shift 2 ;; + *) usage ;; + esac + done + lab_tmux capture-pane -p -J -t "firstmate:$window" -S "-$lines" | grep -v '^[[:space:]]*$' +} + +cmd_check() { + load_lab "${1:-}" + run_checks +} + +case "${1:-}" in + up) shift; cmd_up "$@" ;; + check) shift; cmd_check "$@" ;; + say) shift; cmd_say "$@" ;; + pane) shift; cmd_pane "$@" ;; + down) shift; cmd_down "$@" ;; + -h|--help) help_text ;; + *) usage ;; +esac From 530ff2e4dbe97b9adeb59f8c9c5b2c012dd6555f Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 10:53:41 -0700 Subject: [PATCH 02/19] fix(bin): exact lab windows, per-lab task ids, self-safe teardown --- bin/fm-live-lab.sh | 106 ++++++++++++++++++++++++++++----------------- 1 file changed, 67 insertions(+), 39 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 4730628baa6..11a69e58231 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -20,7 +20,7 @@ # It exits 0 only when every check passed; otherwise it exits 1 and leaves the # lab up for inspection, so run down either way. check re-runs the same checks # once. say types text into a lab window and presses Enter (window main, the -# lab primary, by default; mate and lab-worker are the other windows). pane +# lab primary, by default; mate and worker name the lab's own tasks). pane # prints a window's recent scrollback. down stops every lab process, removes the # lab's Claude trust entries by one atomic replace, removes , and exits # non-zero if anything outside the lab changed. @@ -48,13 +48,16 @@ # only, so the Pi trust store is never written and all of # .pi/extensions loads (one Pi lab lacked the branch # extension); sessions stay under /pi-sessions. -# mate/ --mate: bin/fm-home-seed.sh mate /mate +# task ids lab-mate and lab-worker, unique per lab, +# because a spawn keeps a task temp dir at /tmp/fm- +# that a fixed id would share with other labs and tasks. +# mate/ --mate: bin/fm-home-seed.sh /mate # --no-projects (an explicit path cloned from the git lab # home), launched by bin/fm-spawn.sh --secondmate. -# lab-worker --worker: a lab project notes with a lab-private origin +# worker --worker: a lab project notes with a lab-private origin # (local-only +yolo), a scaffolded brief, a backlog item, and # a real bin/fm-spawn.sh worker that parks on -# /gates/lab-worker until that file exists; touch +# /gates/ until that file exists; touch # it to let the worker finish. # primary window main: claude --setting-sources project,local # (default sonnet, medium, permission mode auto) or pi @@ -90,7 +93,8 @@ # # down refuses any path without the lab record up writes, kills only the lab's # private tmux server and processes whose command line names , runs -# bin/fm-lab-home.sh teardown, removes every ~/.claude.json project entry at or +# bin/fm-lab-home.sh teardown, removes the lab tasks' /tmp/fm- task temp +# dirs, removes every ~/.claude.json project entry at or # under (compare-and-swap atomic replace, unrelated entries kept), # reports a changed Pi trust store or new ~/.treehouse entry without touching # either, and removes . Transcripts under ~/.claude/projects are left @@ -137,6 +141,8 @@ load_lab() { # : refuse anything up did not build, then load its record WANT_MATE=$(rec_get "$ROOT" mate) WANT_WORKER=$(rec_get "$ROOT" worker) NONCE=$(rec_get "$ROOT" nonce) + MATE_ID=$(rec_get "$ROOT" mate_id) + WORKER_ID=$(rec_get "$ROOT" worker_id) PI_TRUST_BEFORE=$(rec_get "$ROOT" pi_trust) } @@ -159,8 +165,22 @@ lab_run() { # [NAME=VALUE...] : run in the lab's clean environment env -i "${base[@]}" "$@" } -window_alive() { # - [ "$(lab_tmux display-message -p -t "firstmate:$1" '#{pane_dead}' 2>/dev/null)" = 0 ] +# window_target : an exact tmux target, because tmux resolves a name it +# cannot find to the current window. mate and worker name the lab's own tasks, +# whose windows the spawn recorded in their metadata. +window_target() { + local name=$1 window + case "$name" in + mate) name=$MATE_ID ;; + worker) name=$WORKER_ID ;; + esac + window=$(sed -n 's/^window=//p' "$LAB/state/$name.meta" 2>/dev/null) + [ -n "$window" ] || window="firstmate:$name" + printf '%s:=%s\n' "${window%%:*}" "${window#*:}" +} + +window_alive() { # + [ "$(lab_tmux display-message -p -t "$(window_target "$1")" '#{pane_dead}' 2>/dev/null)" = 0 ] } pid_alive() { case "${1:-}" in ''|*[!0-9]*) return 1 ;; esac; kill -0 "$1" 2>/dev/null; } @@ -172,7 +192,7 @@ fleet_nonempty() { [ "$WANT_MATE" = yes ] || [ "$WANT_WORKER" = yes ]; } check_primary() { local path gitdir common pid window_alive main || { echo "fail primary: window main is not running"; return 1; } - path=$(lab_tmux display-message -p -t firstmate:main '#{pane_current_path}' 2>/dev/null) + path=$(lab_tmux display-message -p -t firstmate:=main '#{pane_current_path}' 2>/dev/null) [ "$(real_dir "$path")" = "$LAB" ] || { echo "fail primary: window main runs in '$path', not the lab home $LAB"; return 1; } gitdir=$(real_dir "$(git -C "$LAB" rev-parse --absolute-git-dir 2>/dev/null)") common=$(cd "$LAB" && real_dir "$(git rev-parse --git-common-dir 2>/dev/null)") @@ -183,7 +203,7 @@ check_primary() { } check_probe() { - if lab_tmux capture-pane -p -J -t firstmate:main -S -5000 2>/dev/null | grep -Fq "LABREADY-$NONCE"; then + if lab_tmux capture-pane -p -J -t firstmate:=main -S -5000 2>/dev/null | grep -Fq "LABREADY-$NONCE"; then echo "ok probe: the primary answered LABREADY-$NONCE" else echo "fail probe: no LABREADY-$NONCE reply in window main (model refused, turn still running, or a dialog is open)" @@ -267,17 +287,17 @@ check_watcher() { check_mate() { local pid - window_alive mate || { echo "fail mate: window mate is not running"; return 1; } + window_alive mate || { echo "fail mate: the $MATE_ID window is not running"; return 1; } pid=$(sed -n 1p "$ROOT/mate/state/.lock" 2>/dev/null) pid_alive "$pid" || { echo "fail mate: the mate holds no session lock yet (wedged before its charter?)"; return 1; } - echo "ok mate: second mate pid $pid in $ROOT/mate" + echo "ok mate: $MATE_ID pid $pid in $ROOT/mate" } check_worker() { - window_alive lab-worker || { echo "fail worker: window lab-worker is not running"; return 1; } - grep -E '^paused' "$LAB/state/lab-worker.status" 2>/dev/null | grep -Fq "$ROOT/gates/lab-worker" \ + window_alive worker || { echo "fail worker: the $WORKER_ID window is not running"; return 1; } + grep -E '^paused' "$LAB/state/$WORKER_ID.status" 2>/dev/null | grep -Fq "$ROOT/gates/$WORKER_ID" \ || { echo "fail worker: the worker has not declared its gate wait yet"; return 1; } - echo "ok worker: parked on $ROOT/gates/lab-worker" + echo "ok worker: $WORKER_ID parked on $ROOT/gates/$WORKER_ID" } check_treehouse() { @@ -308,9 +328,11 @@ run_checks() { # ---- up --------------------------------------------------------------------- say_text() { # - lab_tmux send-keys -t "firstmate:$1" -l "$2" || return 1 + local target + target=$(window_target "$1") + lab_tmux send-keys -t "$target" -l "$2" || return 1 sleep 1 - lab_tmux send-keys -t "firstmate:$1" Enter + lab_tmux send-keys -t "$target" Enter } make_notes_project() { @@ -364,10 +386,10 @@ MD } spawn_worker() { - local brief="$LAB/data/lab-worker/brief.md" gate="$ROOT/gates/lab-worker" + local brief="$LAB/data/$WORKER_ID/brief.md" gate="$ROOT/gates/$WORKER_ID" mkdir -p "$ROOT/gates" make_notes_project || return 1 - (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-brief.sh" lab-worker notes --mode local-only) >/dev/null || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-brief.sh" "$WORKER_ID" notes --mode local-only) >/dev/null || return 1 TASK_TEXT="Lab gated worker for a live supervision lab. Add count_notes(), which returns how many notes are stored, to notes/__init__.py with a unit test, but only after the gate file $gate exists." \ SPEC_TEXT="Right after setup, append one paused status line that names the gate file $gate, then check for that file every 15 seconds and do nothing else until it exists. Then implement count_notes() in notes/__init__.py and a test in tests/test_notes.py, run python3 -m unittest discover -s tests, commit, and report done. Nothing else is in scope." \ python3 - "$brief" <<'PY' || return 1 @@ -377,8 +399,8 @@ text = open(path, encoding="utf-8").read() text = text.replace("{TASK}", os.environ["TASK_TEXT"], 1).replace("{FIRSTMATE_SPEC}", os.environ["SPEC_TEXT"], 1) open(path, "w", encoding="utf-8").write(text) PY - (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-tasks-axi.sh" add lab-worker "lab gated worker" --kind ship --repo notes) >/dev/null || return 1 - (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" lab-worker "$LAB/projects/notes" \ + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-tasks-axi.sh" add "$WORKER_ID" "lab gated worker" --kind ship --repo notes) >/dev/null || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" "$WORKER_ID" "$LAB/projects/notes" \ --mode local-only --yolo on --harness claude --model sonnet --effort low) } @@ -386,8 +408,8 @@ spawn_mate() { local charter='Provide an idle live-validation lab second mate. When explicitly steered for a synthetic test, report only honest local lab outcomes; do not claim external PR activity, merge, or retire yourself.' (cd "$LAB" && lab_run FM_HOME="$LAB" FM_SECONDMATE_CHARTER="$charter" \ FM_SECONDMATE_SCOPE='second-mate live validation synthetic status relay' \ - "$LAB/bin/fm-home-seed.sh" mate "$ROOT/mate" --no-projects) || return 1 - (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" mate --secondmate) + "$LAB/bin/fm-home-seed.sh" "$MATE_ID" "$ROOT/mate" --no-projects) || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" "$MATE_ID" --secondmate) } cmd_up() { @@ -432,6 +454,7 @@ cmd_up() { LAB="$ROOT/home" HARNESS=$harness EXPECT_HOST=$expect_host WANT_MATE=$mate WANT_WORKER=$worker NONCE=$(od -An -N4 -tx4 /dev/urandom | tr -d ' \n') + MATE_ID="lab${NONCE:0:6}-mate" WORKER_ID="lab${NONCE:0:6}-worker" PI_TRUST_BEFORE=$(digest "$PI_TRUST_STORE") treehouse_listing | sort > "$ROOT/.treehouse-before" { @@ -442,6 +465,8 @@ cmd_up() { echo "mate=$mate" echo "worker=$worker" echo "nonce=$NONCE" + echo "mate_id=$MATE_ID" + echo "worker_id=$WORKER_ID" echo "pi_trust=$PI_TRUST_BEFORE" } > "$ROOT/$RECORD_NAME" echo "lab: $ROOT (tear down with: $0 down $ROOT)" @@ -499,22 +524,19 @@ cmd_up() { # ---- down ------------------------------------------------------------------- -ancestors() { - local pid=$$ - while pid_alive "$pid" && [ "$pid" -gt 1 ]; do - echo "$pid" - pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - done -} - -lab_pids() { # processes whose command line names the lab root, never this one's ancestry - local mine - mine=" $(ancestors | tr '\n' ' ') " - ps -axo pid=,command= | while read -r pid cmd; do - case "$cmd" in *"$ROOT"*|*"${ROOT#/private}"*) ;; *) continue ;; esac - case "$mine" in *" $pid "*) continue ;; esac - echo "$pid" - done +lab_pids() { # processes whose command line names the lab root, never this script's own tree + ps -axo pid=,ppid=,command= | awk -v self=$$ -v a="$ROOT" -v b="${ROOT#/private}" ' + { pid[NR]=$1; ppid[$1]=$2; line=$0; sub(/^ *[0-9]+ +[0-9]+ /, "", line); cmd[$1]=line } + END { + for (p = self; p > 1 && (p in ppid); p = ppid[p]) own[p] = 1 + for (i = 1; i <= NR; i++) { + p = pid[i] + if (p in own) continue + for (q = ppid[p]; q > 1 && (q in ppid); q = ppid[q]) if (q == self) break + if (q == self) continue + if (index(cmd[p], a) || index(cmd[p], b)) print p + } + }' } forget_claude_entries() { # remove every project entry at or under ROOT; prints the count @@ -563,7 +585,7 @@ NODE cmd_down() { load_lab "${1:-}" - local rc=0 pids n=0 removed added + local rc=0 pids n=0 removed added id tasktmp lab_tmux kill-server 2>/dev/null || true while pids=$(lab_pids) && [ -n "$pids" ] && [ "$n" -lt 20 ]; do # shellcheck disable=SC2086 # One pid per word. @@ -573,6 +595,12 @@ cmd_down() { pids=$(lab_pids) [ -z "$pids" ] || die "refusing to remove the lab: processes still name it: $(printf '%s' "$pids" | tr '\n' ' ')" echo "stopped: lab tmux server and lab processes" + for id in "$MATE_ID" "$WORKER_ID"; do + tasktmp=$(sed -n 's/^tasktmp=//p' "$LAB/state/$id.meta" 2>/dev/null) + if [ -n "$id" ] && [ "$tasktmp" = "/tmp/fm-$id" ] && [ -d "$tasktmp" ] && [ ! -L "$tasktmp" ] && [ -O "$tasktmp" ]; then + rm -rf "$tasktmp" && echo "removed: task temp $tasktmp" + fi + done if [ -f "$LAB/.fm-lab-home" ]; then "$LAB_HOME_HELPER" teardown "$LAB" || die "cannot remove the private tmux directory" fi From 4f23477c360202436276d1ff361018280bf25de7 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 11:01:33 -0700 Subject: [PATCH 03/19] fix(bin): target lab windows by id, stop lab descendants, add readiness tests --- bin/fm-live-lab.sh | 102 +++++++---- tests/fm-live-lab.test.sh | 366 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 435 insertions(+), 33 deletions(-) create mode 100755 tests/fm-live-lab.test.sh diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 11a69e58231..4941cd611b2 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -91,14 +91,15 @@ # worker --worker: the worker declared its gate wait in its status. # treehouse ~/.treehouse gained no entry since up began. # -# down refuses any path without the lab record up writes, kills only the lab's -# private tmux server and processes whose command line names , runs -# bin/fm-lab-home.sh teardown, removes the lab tasks' /tmp/fm- task temp -# dirs, removes every ~/.claude.json project entry at or -# under (compare-and-swap atomic replace, unrelated entries kept), -# reports a changed Pi trust store or new ~/.treehouse entry without touching -# either, and removes . Transcripts under ~/.claude/projects are left -# as history. The lab never uses Herdr. +# down refuses any path without the lab record up writes. It kills only the +# lab's private tmux server, the processes whose command line names , +# and their descendants; runs bin/fm-lab-home.sh teardown; removes the task +# temp and launch dirs the lab's spawns kept under /tmp; removes every +# ~/.claude.json project entry at or under (compare-and-swap atomic +# replace, unrelated entries kept); reports a changed Pi trust store or a new +# ~/.treehouse entry without touching either; and removes . +# Transcripts under ~/.claude/projects are left as history. The lab never uses +# Herdr. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" @@ -165,22 +166,32 @@ lab_run() { # [NAME=VALUE...] : run in the lab's clean environment env -i "${base[@]}" "$@" } -# window_target : an exact tmux target, because tmux resolves a name it -# cannot find to the current window. mate and worker name the lab's own tasks, -# whose windows the spawn recorded in their metadata. -window_target() { +# window_id : the tmux id of the lab window with exactly this name, or +# nothing. A name is matched here and never passed as a target, because tmux +# resolves a target it cannot find, even an exact =name, to the current window, +# while a stale window id fails. mate and worker name the lab's own tasks, whose +# windows the spawn recorded in their metadata. +window_id() { local name=$1 window case "$name" in mate) name=$MATE_ID ;; worker) name=$WORKER_ID ;; esac window=$(sed -n 's/^window=//p' "$LAB/state/$name.meta" 2>/dev/null) - [ -n "$window" ] || window="firstmate:$name" - printf '%s:=%s\n' "${window%%:*}" "${window#*:}" + [ -z "$window" ] || name=${window#*:} + lab_tmux list-windows -t firstmate -F "#{window_name}$(printf '\t')#{window_id}" 2>/dev/null \ + | awk -F '\t' -v n="$name" '$1 == n { print $2; exit }' +} + +window_field() { # + local id + id=$(window_id "$1") + [ -n "$id" ] || return 1 + lab_tmux display-message -p -t "$id" "$2" 2>/dev/null } window_alive() { # - [ "$(lab_tmux display-message -p -t "$(window_target "$1")" '#{pane_dead}' 2>/dev/null)" = 0 ] + [ "$(window_field "$1" '#{pane_dead}')" = 0 ] } pid_alive() { case "${1:-}" in ''|*[!0-9]*) return 1 ;; esac; kill -0 "$1" 2>/dev/null; } @@ -192,7 +203,7 @@ fleet_nonempty() { [ "$WANT_MATE" = yes ] || [ "$WANT_WORKER" = yes ]; } check_primary() { local path gitdir common pid window_alive main || { echo "fail primary: window main is not running"; return 1; } - path=$(lab_tmux display-message -p -t firstmate:=main '#{pane_current_path}' 2>/dev/null) + path=$(window_field main '#{pane_current_path}') [ "$(real_dir "$path")" = "$LAB" ] || { echo "fail primary: window main runs in '$path', not the lab home $LAB"; return 1; } gitdir=$(real_dir "$(git -C "$LAB" rev-parse --absolute-git-dir 2>/dev/null)") common=$(cd "$LAB" && real_dir "$(git rev-parse --git-common-dir 2>/dev/null)") @@ -203,7 +214,9 @@ check_primary() { } check_probe() { - if lab_tmux capture-pane -p -J -t firstmate:=main -S -5000 2>/dev/null | grep -Fq "LABREADY-$NONCE"; then + local id + id=$(window_id main) + if [ -n "$id" ] && lab_tmux capture-pane -p -J -t "$id" -S -5000 2>/dev/null | grep -Fq "LABREADY-$NONCE"; then echo "ok probe: the primary answered LABREADY-$NONCE" else echo "fail probe: no LABREADY-$NONCE reply in window main (model refused, turn still running, or a dialog is open)" @@ -328,11 +341,12 @@ run_checks() { # ---- up --------------------------------------------------------------------- say_text() { # - local target - target=$(window_target "$1") - lab_tmux send-keys -t "$target" -l "$2" || return 1 + local id + id=$(window_id "$1") + [ -n "$id" ] || { echo "fm-live-lab: no lab window named '$1'" >&2; return 1; } + lab_tmux send-keys -t "$id" -l "$2" || return 1 sleep 1 - lab_tmux send-keys -t "$target" Enter + lab_tmux send-keys -t "$id" Enter } make_notes_project() { @@ -501,7 +515,7 @@ cmd_up() { lab_tmux new-window -d -t firstmate: -n main -c "$LAB" \ env FM_HOME="$LAB" CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false ${extra_env[@]+"${extra_env[@]}"} "${primary[@]}" \ || die "cannot launch the lab primary" - lab_tmux set-option -w -t firstmate:main remain-on-exit on >/dev/null + lab_tmux set-option -w -t "$(window_id main)" remain-on-exit on >/dev/null echo "primary: ${primary[*]}" local deadline=$(( $(date +%s) + 180 )) @@ -524,17 +538,28 @@ cmd_up() { # ---- down ------------------------------------------------------------------- -lab_pids() { # processes whose command line names the lab root, never this script's own tree +# lab_pids: processes whose command line names the lab root, and every +# descendant of one (a child such as an engine turn need not name the root), +# never this script's own ancestry or descendants. +lab_pids() { ps -axo pid=,ppid=,command= | awk -v self=$$ -v a="$ROOT" -v b="${ROOT#/private}" ' { pid[NR]=$1; ppid[$1]=$2; line=$0; sub(/^ *[0-9]+ +[0-9]+ /, "", line); cmd[$1]=line } END { for (p = self; p > 1 && (p in ppid); p = ppid[p]) own[p] = 1 for (i = 1; i <= NR; i++) { p = pid[i] - if (p in own) continue for (q = ppid[p]; q > 1 && (q in ppid); q = ppid[q]) if (q == self) break - if (q == self) continue - if (index(cmd[p], a) || index(cmd[p], b)) print p + if (q == self) own[p] = 1 + } + for (i = 1; i <= NR; i++) { + p = pid[i] + if (p in own) continue + if (index(cmd[p], a) || index(cmd[p], b)) named[p] = 1 + } + for (i = 1; i <= NR; i++) { + p = pid[i] + if (p in own) continue + for (q = p; q > 1 && (q in ppid); q = ppid[q]) if (q in named) { print p; break } } }' } @@ -585,7 +610,7 @@ NODE cmd_down() { load_lab "${1:-}" - local rc=0 pids n=0 removed added id tasktmp + local rc=0 pids n=0 removed added id meta dir home_hash lab_tmux kill-server 2>/dev/null || true while pids=$(lab_pids) && [ -n "$pids" ] && [ "$n" -lt 20 ]; do # shellcheck disable=SC2086 # One pid per word. @@ -595,11 +620,19 @@ cmd_down() { pids=$(lab_pids) [ -z "$pids" ] || die "refusing to remove the lab: processes still name it: $(printf '%s' "$pids" | tr '\n' ' ')" echo "stopped: lab tmux server and lab processes" - for id in "$MATE_ID" "$WORKER_ID"; do - tasktmp=$(sed -n 's/^tasktmp=//p' "$LAB/state/$id.meta" 2>/dev/null) - if [ -n "$id" ] && [ "$tasktmp" = "/tmp/fm-$id" ] && [ -d "$tasktmp" ] && [ ! -L "$tasktmp" ] && [ -O "$tasktmp" ]; then - rm -rf "$tasktmp" && echo "removed: task temp $tasktmp" - fi + # A spawn keeps /tmp/fm- and /tmp/fm-+. + # The second is scoped to this lab home for any task it spawned; the first is + # removed only for the lab's own unique ids, since another home may share it. + home_hash=$(printf '%s' "$LAB" | shasum -a 256 | awk '{print $1}') + for meta in "$LAB"/state/*.meta; do + [ -f "$meta" ] || continue + id=$(basename "$meta" .meta) + for dir in "/tmp/fm-$id+$home_hash" "/tmp/fm-$id"; do + [ "$dir" != "/tmp/fm-$id" ] || [ "$id" = "$MATE_ID" ] || [ "$id" = "$WORKER_ID" ] || continue + if [ -d "$dir" ] && [ ! -L "$dir" ] && [ -O "$dir" ]; then + rm -rf "$dir" && echo "removed: task temp $dir" + fi + done done if [ -f "$LAB/.fm-lab-home" ]; then "$LAB_HOME_HELPER" teardown "$LAB" || die "cannot remove the private tmux directory" @@ -642,7 +675,10 @@ cmd_pane() { *) usage ;; esac done - lab_tmux capture-pane -p -J -t "firstmate:$window" -S "-$lines" | grep -v '^[[:space:]]*$' + local id + id=$(window_id "$window") + [ -n "$id" ] || die "no lab window named '$window'" + lab_tmux capture-pane -p -J -t "$id" -S "-$lines" | grep -v '^[[:space:]]*$' } cmd_check() { diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh new file mode 100755 index 00000000000..97dbdb5e310 --- /dev/null +++ b/tests/fm-live-lab.test.sh @@ -0,0 +1,366 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-live-lab.sh (readiness checks and teardown) and +# bin/fm-claude-trust.sh --lab-home. +# +# Every readiness check is proven both ways against a real private tmux server +# and real processes, with no harness: it passes on a lab in the shape up builds +# and fails, by name, on the recorded lab miss it exists to catch. The live +# end-to-end run on the real harnesses is the builder's own `up`. +set -u + +# shellcheck source=tests/fixtures.sh +. "$(dirname "${BASH_SOURCE[0]}")/fixtures.sh" + +TMP_ROOT=$(fm_test_tmproot fm-live-lab) +: > "$TMP_ROOT/pids" +: > "$TMP_ROOT/tmux-dirs" +LIVE_LAB="$ROOT/bin/fm-live-lab.sh" +TRUST="$ROOT/bin/fm-claude-trust.sh" + +live_lab_cleanup() { + local dir pid + while read -r pid; do [ -n "$pid" ] && kill "$pid" 2>/dev/null; done < "$TMP_ROOT/pids" + while read -r dir; do + [ -n "$dir" ] || continue + env -u TMUX TMUX_TMPDIR="$dir" tmux kill-server 2>/dev/null + case "$dir" in /tmp/fml.*) rm -rf "$dir" ;; esac + done < "$TMP_ROOT/tmux-dirs" + rm -rf "/tmp/fm-labt$$-mate" "/tmp/fm-labt$$-worker" "/tmp/fm-labt$$-other" /tmp/fm-labt"$$"-*+* + fm_test_cleanup +} +trap live_lab_cleanup EXIT + +command -v tmux >/dev/null 2>&1 || { echo "ok - skipped: tmux is not installed"; exit 0; } + +FAKE_HOME="$TMP_ROOT/fakehome" +mkdir -p "$FAKE_HOME/.pi/agent" "$FAKE_HOME/.treehouse/existing-pool" +printf '{}\n' > "$FAKE_HOME/.pi/agent/trust.json" +export HOME="$FAKE_HOME" +unset CLAUDE_CONFIG_DIR TMUX + +MATE_ID="labt$$-mate" +WORKER_ID="labt$$-worker" +NONCE=abc12345 + +digest() { shasum -a 256 "$1" | awk '{print $1}'; } + +# make_lab : a lab root in the shape up builds, with a live +# private tmux server. Every readiness input starts in its passing state. +make_lab() { + local root="$TMP_ROOT/$1" harness=$2 home tmux_dir lock_pid + home="$root/home" + mkdir -p "$root" + "$ROOT/bin/fm-lab-home.sh" create "$home" >/dev/null || fail "lab home create" + cp -R "$ROOT/bin" "$home/bin" + cp "$ROOT/AGENTS.md" "$home/AGENTS.md" + mkdir -p "$home/.pi" "$root/mate/state" "$root/gates" "$root/treehouse" + cp -R "$ROOT/.pi/extensions" "$home/.pi/extensions" + git -C "$home" init -q -b main + git -C "$home" add -A bin AGENTS.md .pi + git -C "$home" -c user.name=t -c user.email=t@example.invalid commit -qm lab + tmux_dir=$("$ROOT/bin/fm-lab-home.sh" tmux-dir "$home") || fail "lab tmux dir" + printf '%s\n' "$tmux_dir" >> "$TMP_ROOT/tmux-dirs" + ls -1A "$HOME/.treehouse" | sort > "$root/.treehouse-before" + { + echo 'fm-live-lab v1' + echo "harness=$harness" + echo "home=$home" + echo "expect_host=yes" + echo "mate=yes" + echo "worker=yes" + echo "nonce=$NONCE" + echo "mate_id=$MATE_ID" + echo "worker_id=$WORKER_ID" + echo "pi_trust=$(digest "$HOME/.pi/agent/trust.json")" + echo "tmux_dir=$tmux_dir" + } > "$root/.fm-live-lab" + + lab_tmux "$root" new-session -d -s firstmate -n lab -c "$root" 'exec sleep 600' + lab_tmux "$root" new-window -d -t firstmate: -n main -c "$home" "printf 'LABREADY-$NONCE\n'; exec sleep 600" + lab_tmux "$root" new-window -d -t firstmate: -n "fm-$MATE_ID" -c "$root/mate" 'exec sleep 600' + lab_tmux "$root" new-window -d -t firstmate: -n "fm-$WORKER_ID" -c "$root" 'exec sleep 600' + fm_write_meta "$home/state/$MATE_ID.meta" "window=firstmate:fm-$MATE_ID" "tasktmp=/tmp/fm-$MATE_ID" + fm_write_meta "$home/state/$WORKER_ID.meta" "window=firstmate:fm-$WORKER_ID" "tasktmp=/tmp/fm-$WORKER_ID" + printf 'paused [at=1]: waiting on gate file %s to exist\n' "$root/gates/$WORKER_ID" > "$home/state/$WORKER_ID.status" + + lock_pid=$(lab_tmux "$root" display-message -p -t firstmate:=main '#{pane_pid}') + printf '%s\n' "$lock_pid" > "$home/state/.lock" + printf '%s\n' "$(lab_tmux "$root" display-message -p -t "firstmate:=fm-$MATE_ID" '#{pane_pid}')" > "$root/mate/state/.lock" + start_watcher "$home" + printf 'host\t%s\tx\n' "$(start_sleeper)" > "$home/state/.supervision-host" + printf '%s\n' \ + '{"seq":1,"epoch":1,"key":"k","id":"a","tag":"captain","text":"probe"}' \ + '{"seq":2,"epoch":2,"key":"k","id":"b","tag":"main","text":"LABREADY"}' > "$home/state/.host-mirror.jsonl" + write_pi_markers "$home" "$lock_pid" + [ "$harness" = claude ] && node -e 'const fs=require("node:fs");const [s,h,r]=process.argv.slice(1);fs.writeFileSync(s,JSON.stringify({keep:1,projects:{[h]:{hasTrustDialogAccepted:true},[r+"/mate"]:{hasTrustDialogAccepted:true},"/elsewhere/project":{hasTrustDialogAccepted:true}}},null,2)+"\n")' \ + "$HOME/.claude.json" "$home" "$root" + printf '%s\n' "$root" +} + +lab_tmux() { # + local dir + dir=$(sed -n 's/^tmux_dir=//p' "$1/.fm-live-lab") + shift + env -u TMUX TMUX_TMPDIR="$dir" tmux "$@" +} + +start_sleeper() { + sleep 600 >/dev/null 2>&1 & + printf '%s\n' "$!" >> "$TMP_ROOT/pids" + printf '%s\n' "$!" +} + +start_watcher() { # : a live process holding a matching watcher lock + local home=$1 pid lock="$1/state/.watch.lock" + pid=$(start_sleeper) + mkdir -p "$lock" + printf '%s\n' "$pid" > "$lock/pid" + printf '%s\n' "$home" > "$lock/fm-home" + printf '%s\n' "$home/bin/fm-watch.sh" > "$lock/watcher-path" + fm_test_pid_identity "$pid" > "$lock/pid-identity" + touch "$home/state/.last-watcher-beat" +} + +write_pi_markers() { # + local home=$1 pid=$2 v + v() { FM_HOME="$home" bash -c '. "$1/bin/fm-wake-lib.sh"; fm_pi_extension_version "$1/.pi/extensions/$2"' _ "$home" "$1"; } + printf '%s\n%s\ngeneration=1 phase=active\n' "$(v fm-primary-pi-watch.ts)" "$pid" > "$home/state/.pi-watch-extension-loaded" + printf '%s\n%s\n' "$(v fm-primary-turnend-guard.ts)" "$pid" > "$home/state/.pi-turnend-extension-loaded" + printf '%s\n' "$pid" > "$home/state/.pi-branch-extension-loaded" +} + +run_check() { # : sets CHECK_OUT and CHECK_RC + CHECK_OUT=$("$LIVE_LAB" check "$1" 2>&1) + CHECK_RC=$? +} + +set_record() { # + sed -i.bak "s|^$2=.*|$2=$3|" "$1/.fm-live-lab" && rm -f "$1/.fm-live-lab.bak" +} + +# ---- Claude lab: every check passes on the shape up builds ----------------- + +C=$(make_lab c claude) +CH="$C/home" +run_check "$C" +expect_code 0 "$CHECK_RC" "a Claude lab in up's shape is ready: $CHECK_OUT" +for name in primary probe trust mirror host watcher mate worker treehouse; do + assert_contains "$CHECK_OUT" "ok $name:" "the $name check passes on a ready Claude lab" +done +pass "a ready Claude lab passes every readiness check" + +# primary: the lab session lock must name a live process (session start ran). +printf '999999\n' > "$CH/state/.lock" +run_check "$C" +expect_code 1 "$CHECK_RC" "a dead session lock is not ready" +assert_contains "$CHECK_OUT" "fail primary: the lab session lock names no live process" "primary names the dead lock" +lab_tmux "$C" display-message -p -t firstmate:=main '#{pane_pid}' > "$CH/state/.lock" +pass "primary fails when session start never took the lab lock" + +# primary: a lab home that is a linked worktree is not the genuine primary +# checkout a mirrored Claude primary needs. +WT_CASE="$TMP_ROOT/wtcase" +fm_git_worktree "$WT_CASE/project" "$WT_CASE/wt" lab-wt +cp "$C/.fm-live-lab" "$WT_CASE/.fm-live-lab" +set_record "$WT_CASE" home "$WT_CASE/wt" +lab_tmux "$C" new-window -d -t firstmate: -n wtmain -c "$WT_CASE/wt" 'exec sleep 600' +lab_tmux "$C" kill-window -t firstmate:=main +lab_tmux "$C" rename-window -t firstmate:=wtmain main +run_check "$WT_CASE" +assert_contains "$CHECK_OUT" "fail primary: the lab home is not a primary checkout" "primary refuses a linked-worktree home" +lab_tmux "$C" kill-window -t firstmate:=main +lab_tmux "$C" new-window -d -t firstmate: -n main -c "$CH" "printf 'LABREADY-$NONCE\n'; exec sleep 600" +lab_tmux "$C" display-message -p -t firstmate:=main '#{pane_pid}' > "$CH/state/.lock" +pass "primary fails when the primary runs in a linked worktree instead of the lab's primary checkout" + +# probe: the nonce reply proves the model is accepted and a turn completed. +set_record "$C" nonce 00000000 +run_check "$C" +assert_contains "$CHECK_OUT" "fail probe: no LABREADY-00000000 reply" "probe names the missing reply" +set_record "$C" nonce "$NONCE" +pass "probe fails when the primary never answered its nonce" + +# trust: the workspace-trust prompt wedged the first lab. +cp "$HOME/.claude.json" "$TMP_ROOT/claude.json.keep" +node -e 'const fs=require("node:fs");const [s,h]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,"utf8"));delete j.projects[h];fs.writeFileSync(s,JSON.stringify(j))' "$HOME/.claude.json" "$CH" +run_check "$C" +assert_contains "$CHECK_OUT" "fail trust: $CH has no registered Claude workspace trust" "trust names the untrusted home" +cp "$TMP_ROOT/claude.json.keep" "$HOME/.claude.json" +pass "trust fails when the lab home has no registered Claude trust" + +# mirror: the dialog mirror feed must be wired and hold both sides. +cp "$CH/state/.host-mirror.jsonl" "$TMP_ROOT/mirror.keep" +head -n 1 "$TMP_ROOT/mirror.keep" > "$CH/state/.host-mirror.jsonl" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mirror: the dialog mirror has no captain and main entry yet (captain=1 main=0)" "mirror needs a main entry" +rm -f "$CH/state/.host-mirror.jsonl" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mirror: fm-host-mirror.sh check exited 1" "mirror fails without a mirror file" +cp "$TMP_ROOT/mirror.keep" "$CH/state/.host-mirror.jsonl" +pass "mirror fails when the feed is unwired or has not recorded a whole turn" + +# host: expected on Claude, refused when it should be absent. +host_pid=$(awk -F '\t' '{print $2}' "$CH/state/.supervision-host") +kill "$host_pid" 2>/dev/null +wait "$host_pid" 2>/dev/null +run_check "$C" +assert_contains "$CHECK_OUT" "fail host: no live supervision host (expected one)" "host names the missing host" +set_record "$C" expect_host no +run_check "$C" +assert_contains "$CHECK_OUT" "ok host: none running, as expected" "an opted-out lab expects no host" +assert_not_contains "$CHECK_OUT" "mirror" "an opted-out lab skips the mirror" +host_pid=$(start_sleeper) +printf 'host\t%s\tx\n' "$host_pid" > "$CH/state/.supervision-host" +run_check "$C" +assert_contains "$CHECK_OUT" "fail host: supervision host pid $host_pid runs (expected none)" "an opted-out lab refuses a host" +set_record "$C" expect_host yes +pass "host passes and fails according to --expect-host" + +# watcher: a stale beacon is not supervision. +touch -t 202001010000 "$CH/state/.last-watcher-beat" +run_check "$C" +assert_contains "$CHECK_OUT" "fail watcher: no live watcher with a fresh beacon" "watcher names the stale beacon" +touch "$CH/state/.last-watcher-beat" +pass "watcher fails on a stale beacon" + +# mate: its own window, targeted exactly. A missing window must not resolve to +# another one (tmux falls back to the current window for an unknown name). +lab_tmux "$C" kill-window -t "firstmate:=fm-$MATE_ID" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mate: the $MATE_ID window is not running" "mate names its missing window" +lab_tmux "$C" new-window -d -t firstmate: -n "fm-$MATE_ID" -c "$C/mate" 'exec sleep 600' +rm -f "$C/mate/state/.lock" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mate: the mate holds no session lock yet" "mate needs its own session lock" +lab_tmux "$C" display-message -p -t "firstmate:=fm-$MATE_ID" '#{pane_pid}' > "$C/mate/state/.lock" +pass "mate fails when its window is gone or it never reached its charter" + +# worker: it must have declared its gate wait. +: > "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "fail worker: the worker has not declared its gate wait yet" "worker needs its paused line" +printf 'paused [at=1]: waiting on gate file %s to exist\n' "$C/gates/$WORKER_ID" > "$CH/state/$WORKER_ID.status" +pass "worker fails until the worker parks on its gate" + +# treehouse: a worker pool must land inside the lab, never in ~/.treehouse. +mkdir "$HOME/.treehouse/notes-leaked" +run_check "$C" +assert_contains "$CHECK_OUT" "fail treehouse: new ~/.treehouse entries: notes-leaked" "treehouse names the leaked pool" +rmdir "$HOME/.treehouse/notes-leaked" +run_check "$C" +expect_code 0 "$CHECK_RC" "the Claude lab is ready again after every restore: $CHECK_OUT" +pass "treehouse fails when a pool lands in ~/.treehouse" + +# ---- Pi lab: extensions and the session-only trust store ------------------- + +P=$(make_lab p pi) +PH="$P/home" +run_check "$P" +expect_code 0 "$CHECK_RC" "a Pi lab in up's shape is ready: $CHECK_OUT" +assert_contains "$CHECK_OUT" "ok extensions: fm-primary-pi-watch fm-primary-turnend-guard fm-branch-supervision" "all three extensions load" +assert_contains "$CHECK_OUT" "ok trust: Pi trust store unchanged" "the Pi trust store is untouched" +assert_not_contains "$CHECK_OUT" "mirror" "a Pi lab has no host mirror check" +rm -f "$PH/state/.pi-branch-extension-loaded" +run_check "$P" +assert_contains "$CHECK_OUT" "fail extensions: fm-branch-supervision.ts is not loaded by the lock holder" "the missing branch extension is named" +printf '%s\n' "$(sed -n 1p "$PH/state/.lock")" > "$PH/state/.pi-branch-extension-loaded" +printf 'stale\n' > "$PH/state/.pi-turnend-extension-loaded" +run_check "$P" +assert_contains "$CHECK_OUT" "fail extensions: fm-primary-turnend-guard.ts is not loaded at its current build" "a stale turn-end build is named" +pass "extensions fail when the Pi lab lacks the branch extension or loads a stale build" + +# ---- down ------------------------------------------------------------------- + +NOT_LAB="$TMP_ROOT/not-a-lab" +mkdir -p "$NOT_LAB/keep" +out=$("$LIVE_LAB" down "$NOT_LAB" 2>&1) +expect_code 1 "$?" "down refuses a path without a lab record" +assert_contains "$out" "carries no lab record" "the refusal names the missing record" +assert_present "$NOT_LAB/keep" "a refused down removes nothing" +pass "down refuses anything up did not build" + +C_HASH=$(printf '%s' "$CH" | shasum -a 256 | awk '{print $1}') +OTHER_ID="labt$$-other" +fm_write_meta "$CH/state/$OTHER_ID.meta" "window=firstmate:fm-$OTHER_ID" "tasktmp=/tmp/fm-$OTHER_ID" +mkdir -p "/tmp/fm-$WORKER_ID/gotmp" "/tmp/fm-$MATE_ID" "/tmp/fm-$WORKER_ID+$C_HASH" "/tmp/fm-$OTHER_ID+$C_HASH" "/tmp/fm-$OTHER_ID" +printf 'sleep 600\n' > "$C/stray.sh" +bash "$C/stray.sh" & +STRAY=$! +until STRAY_CHILD=$(pgrep -P "$STRAY" sleep); do sleep 0.1; done +C_TMUX=$(sed -n 's/^tmux_dir=//p' "$C/.fm-live-lab") +out=$("$LIVE_LAB" down "$C" 2>&1) +expect_code 0 "$?" "down of a clean Claude lab succeeds: $out" +! kill -0 "$STRAY" 2>/dev/null || fail "down stops processes that name the lab root" +! kill -0 "$STRAY_CHILD" 2>/dev/null || fail "down stops their descendants, which need not name the root" +assert_absent "$C" "down removes the lab root" +assert_absent "$C_TMUX" "down removes the private tmux directory" +assert_absent "/tmp/fm-$WORKER_ID" "down removes the worker's task temp dir" +assert_absent "/tmp/fm-$MATE_ID" "down removes the mate's task temp dir" +assert_absent "/tmp/fm-$WORKER_ID+$C_HASH" "down removes the worker's launch dir" +assert_absent "/tmp/fm-$OTHER_ID+$C_HASH" "down removes a lab-spawned task's launch dir scoped to the lab home" +assert_present "/tmp/fm-$OTHER_ID" "down keeps a task temp dir another home could share" +kept=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(JSON.stringify([j.keep,Object.keys(j.projects).sort()]))' "$HOME/.claude.json") +assert_equals '[1,["/elsewhere/project"]]' "$kept" "down removes exactly the lab's Claude project entries" +assert_contains "$out" "removed: 2 Claude project entries" "down reports the removed entries" +pass "down stops the lab, removes its trust entries and task temp dirs, and keeps everything else" + +printf '{"trusted":["/somewhere"]}\n' > "$HOME/.pi/agent/trust.json" +run_check "$P" +assert_contains "$CHECK_OUT" "fail trust: the Pi trust store changed since up began" "a written Pi trust store is caught" +pass "trust fails on Pi when the lab wrote the persistent Pi trust store" + +out=$("$LIVE_LAB" down "$P" 2>&1) +expect_code 1 "$?" "down reports a changed Pi trust store" +assert_contains "$out" "the Pi trust store changed since up began; left as is" "the Pi trust change is named" +assert_absent "$P" "the lab is still removed" +assert_equals '{"trusted":["/somewhere"]}' "$(cat "$HOME/.pi/agent/trust.json")" "down never rewrites the Pi trust store" +pass "down removes the lab but reports, without reverting, a written Pi trust store" + +# ---- up argument safety ----------------------------------------------------- + +EXISTING="$TMP_ROOT/existing" +mkdir -p "$EXISTING/keep" +out=$("$LIVE_LAB" up --harness claude "$EXISTING" 2>&1) +expect_code 1 "$?" "up refuses an existing lab root" +assert_contains "$out" "a lab root must not exist yet" "the refusal names the existing root" +assert_present "$EXISTING/keep" "a refused up touches nothing" +out=$("$LIVE_LAB" up --harness codex "$TMP_ROOT/new" 2>&1) +expect_code 1 "$?" "up refuses an unsupported harness" +assert_absent "$TMP_ROOT/new" "a refused harness creates nothing" +pass "up refuses an existing root and an unsupported harness" + +# ---- fm-claude-trust.sh --lab-home ------------------------------------------- + +T="$TMP_ROOT/trust" +mkdir -p "$T/config" +"$ROOT/bin/fm-lab-home.sh" create "$T/home" >/dev/null +cp "$ROOT/AGENTS.md" "$T/home/AGENTS.md" +mkdir -p "$T/home/bin" +git -C "$T/home" init -q -b main +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/home" 2>&1) +expect_code 0 "$?" "a marked lab primary checkout is trusted: $out" +TH=$(cd -P "$T/home" && pwd -P) +node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects[process.argv[2]].hasTrustDialogAccepted===true&&!("hasClaudeMdExternalIncludesApproved" in j.projects[process.argv[2]])?0:1)' \ + "$T/config/.claude.json" "$TH" || fail "lab-home trust is trust-only" + +mkdir -p "$T/plain/bin" +cp "$ROOT/AGENTS.md" "$T/plain/AGENTS.md" +git -C "$T/plain" init -q -b main +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/plain" 2>&1) +expect_code 1 "$?" "an unmarked checkout is refused" +assert_contains "$out" "carries no lab-home marker" "the refusal names the missing marker" + +fm_git_worktree "$T/proj" "$T/wt" lab-trust-wt +printf 'fm-lab-home v1\n' > "$T/wt/.fm-lab-home" +cp "$ROOT/AGENTS.md" "$T/wt/AGENTS.md" +mkdir -p "$T/wt/bin" +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/wt" 2>&1) +expect_code 1 "$?" "a linked worktree is refused" +assert_contains "$out" "is a linked worktree" "the refusal names the linked worktree" + +rm "$T/home/.fm-lab-home" +ln -s "$T/wt/.fm-lab-home" "$T/home/.fm-lab-home" +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/home" 2>&1) +expect_code 1 "$?" "a symlinked marker is refused" +assert_contains "$out" "is a symlink" "the refusal names the symlink" +pass "fm-claude-trust.sh --lab-home trusts only a marked lab primary checkout" From 1b688ff3a8e37abfa4c439762bce6d87631436b0 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 11:04:09 -0700 Subject: [PATCH 04/19] fix(bin): keep Claude's auto-updater off in live labs; list fm-live-lab.sh --- bin/fm-live-lab.sh | 6 ++++-- docs/scripts.md | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 4941cd611b2..07af017349a 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -40,7 +40,9 @@ # TMUX, Herdr, or Pi marker reaches a lab process (a probe # that inherited the caller's Pi marker was discarded). # TREEHOUSE_ROOT points into , so a worker's pool -# never lands in ~/.treehouse. +# never lands in ~/.treehouse, and DISABLE_AUTOUPDATER=1 +# keeps Claude Code from replacing the shared binary under +# a running lab, as every live run does (tests/lib.sh). # trust Claude: bin/fm-claude-trust.sh --lab-home for the primary # (the workspace-trust prompt wedged the first lab), and the # spawn's own registration for the mate and worker. Pi: @@ -156,7 +158,7 @@ lab_tmux() { lab_env_base() { printf '%s\n' "HOME=$HOME" "USER=${USER:-$(id -un)}" "LOGNAME=${USER:-$(id -un)}" \ "PATH=$PATH" "SHELL=${SHELL:-/bin/zsh}" "TERM=xterm-256color" "LANG=${LANG:-en_US.UTF-8}" \ - "TMUX_TMPDIR=$TMUX_DIR" "TREEHOUSE_ROOT=$ROOT/treehouse" "FM_BACKEND=tmux" + "TMUX_TMPDIR=$TMUX_DIR" "TREEHOUSE_ROOT=$ROOT/treehouse" "FM_BACKEND=tmux" "DISABLE_AUTOUPDATER=1" } lab_run() { # [NAME=VALUE...] : run in the lab's clean environment diff --git a/docs/scripts.md b/docs/scripts.md index 49d794bb533..07e81904e70 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -39,6 +39,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-herdr-lab-viewer.py` | The pty engine behind `fm-herdr-lab.sh viewer`: one real foreground Herdr client on a non-zero window grid | | `fm-lab-home.sh` | Mint disposable lab homes and manage their isolated tmux socket directories | +| `fm-live-lab.sh` | Stand up, readiness-check, drive, and cleanly tear down a disposable live supervision lab (Claude or Pi primary, optional seeded second mate and gated worker) | | `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks | | `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees | | `fm-herdr-ci-cleanup.sh` | Snapshot and tear down only job-owned `fm-lab-*` sessions in the Herdr CI lane | From 17c4c9bf7c27822b9d716796a4403d326749da48 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 11:05:10 -0700 Subject: [PATCH 05/19] fix(bin): start the lab tmux server without user config --- bin/fm-live-lab.sh | 5 +++-- tests/fm-live-lab.test.sh | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 07af017349a..ef342816423 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -36,7 +36,8 @@ # supervision-host (default claude on Claude, absent # on Pi; none leaves the file absent). # tmux server private, through the lab home's bin/fm-lab-home.sh -# tmux-dir, started from an empty environment so no inherited +# tmux-dir, with no user tmux config (its plugins never run +# in a lab), started from an empty environment so no inherited # TMUX, Herdr, or Pi marker reaches a lab process (a probe # that inherited the caller's Pi marker was discarded). # TREEHOUSE_ROOT points into , so a worker's pool @@ -502,7 +503,7 @@ cmd_up() { TMUX_DIR=$("$LAB_HOME_HELPER" tmux-dir "$LAB") || die "cannot create the private tmux directory" echo "tmux_dir=$TMUX_DIR" >> "$ROOT/$RECORD_NAME" - lab_run tmux new-session -d -s firstmate -n lab -x 220 -y 60 -c "$ROOT" || die "cannot start the lab tmux server" + lab_run tmux -f /dev/null new-session -d -s firstmate -n lab -x 220 -y 60 -c "$ROOT" || die "cannot start the lab tmux server" if [ "$mate" = yes ]; then spawn_mate || die "cannot seed and launch the second mate"; fi if [ "$worker" = yes ]; then spawn_worker || die "cannot launch the gated worker"; fi diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 97dbdb5e310..8afd5dbb264 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -60,7 +60,7 @@ make_lab() { git -C "$home" -c user.name=t -c user.email=t@example.invalid commit -qm lab tmux_dir=$("$ROOT/bin/fm-lab-home.sh" tmux-dir "$home") || fail "lab tmux dir" printf '%s\n' "$tmux_dir" >> "$TMP_ROOT/tmux-dirs" - ls -1A "$HOME/.treehouse" | sort > "$root/.treehouse-before" + find "$HOME/.treehouse" -mindepth 1 -maxdepth 1 -exec basename {} \; | sort > "$root/.treehouse-before" { echo 'fm-live-lab v1' echo "harness=$harness" From 0a9bf8a989a409788e979b6456a49f3f8e863def Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 11:15:53 -0700 Subject: [PATCH 06/19] no-mistakes(review): Scope lab teardown to its store, root, and task ids --- bin/fm-live-lab.sh | 72 +++++++++++++++++++++++++++------------ tests/fm-live-lab.test.sh | 44 ++++++++++++++++++++---- 2 files changed, 89 insertions(+), 27 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index ef342816423..357defa031e 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -8,8 +8,8 @@ # fm-live-lab.sh up --harness claude|pi [--mate] [--worker] # [--model ] [--effort ] # [--supervision-host |none] [--expect-host yes|no] -# [--source ] [--ref ] [--env NAME=VALUE]... -# [--timeout ] [] +# [--source ] [--ref ] [--timeout ] +# [] # fm-live-lab.sh check # fm-live-lab.sh say [--window ] # fm-live-lab.sh pane [--window ] [--lines ] @@ -44,6 +44,9 @@ # never lands in ~/.treehouse, and DISABLE_AUTOUPDATER=1 # keeps Claude Code from replacing the shared binary under # a running lab, as every live run does (tests/lib.sh). +# A set CLAUDE_CONFIG_DIR (absolute) is recorded at up and +# passed to every lab process, so up, check, and down all +# use that one Claude store, even from a later shell. # trust Claude: bin/fm-claude-trust.sh --lab-home for the primary # (the workspace-trust prompt wedged the first lab), and the # spawn's own registration for the mate and worker. Pi: @@ -95,11 +98,13 @@ # treehouse ~/.treehouse gained no entry since up began. # # down refuses any path without the lab record up writes. It kills only the -# lab's private tmux server, the processes whose command line names , -# and their descendants; runs bin/fm-lab-home.sh teardown; removes the task -# temp and launch dirs the lab's spawns kept under /tmp; removes every -# ~/.claude.json project entry at or under (compare-and-swap atomic -# replace, unrelated entries kept); reports a changed Pi trust store or a new +# lab's private tmux server, the processes whose command line names +# as a whole path (never a sibling such as 2), and their descendants; +# runs bin/fm-lab-home.sh teardown; removes the task temp and launch dirs the +# lab's spawns kept under /tmp, including a failed spawn's; removes every +# project entry at or under from the recorded Claude store, following +# a symlinked store to its target (compare-and-swap atomic replace, unrelated +# entries kept); reports a changed Pi trust store or a new # ~/.treehouse entry without touching either; and removes . # Transcripts under ~/.claude/projects are left as history. The lab never uses # Herdr. @@ -148,8 +153,11 @@ load_lab() { # : refuse anything up did not build, then load its record MATE_ID=$(rec_get "$ROOT" mate_id) WORKER_ID=$(rec_get "$ROOT" worker_id) PI_TRUST_BEFORE=$(rec_get "$ROOT" pi_trust) + CLAUDE_DIR=$(rec_get "$ROOT" claude_config_dir) } +claude_store() { echo "${CLAUDE_DIR:-$HOME}/.claude.json"; } + lab_tmux() { [ -n "${TMUX_DIR:-}" ] || return 1 env -u TMUX TMUX_TMPDIR="$TMUX_DIR" tmux "$@" @@ -160,6 +168,7 @@ lab_env_base() { printf '%s\n' "HOME=$HOME" "USER=${USER:-$(id -un)}" "LOGNAME=${USER:-$(id -un)}" \ "PATH=$PATH" "SHELL=${SHELL:-/bin/zsh}" "TERM=xterm-256color" "LANG=${LANG:-en_US.UTF-8}" \ "TMUX_TMPDIR=$TMUX_DIR" "TREEHOUSE_ROOT=$ROOT/treehouse" "FM_BACKEND=tmux" "DISABLE_AUTOUPDATER=1" + [ -z "${CLAUDE_DIR:-}" ] || printf '%s\n' "CLAUDE_CONFIG_DIR=$CLAUDE_DIR" } lab_run() { # [NAME=VALUE...] : run in the lab's clean environment @@ -235,7 +244,7 @@ check_trust() { return 0 fi if node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' \ - "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json" "$LAB" 2>/dev/null; then + "$(claude_store)" "$LAB" 2>/dev/null; then echo "ok trust: $LAB is trusted in the Claude store" else echo "fail trust: $LAB has no registered Claude workspace trust" @@ -431,7 +440,6 @@ spawn_mate() { cmd_up() { local harness="" mate=no worker=no model="" effort=medium host_line=__default__ expect_host="" source="$BUILDER_ROOT" ref=HEAD timeout=600 - local -a extra_env=() local root="" while [ "$#" -gt 0 ]; do case "$1" in @@ -444,7 +452,6 @@ cmd_up() { --expect-host) expect_host=${2:-}; shift 2 ;; --source) source=${2:-}; shift 2 ;; --ref) ref=${2:-}; shift 2 ;; - --env) case "${2:-}" in [A-Za-z_]*=*) extra_env+=("$2") ;; *) die "--env takes NAME=VALUE" ;; esac; shift 2 ;; --timeout) timeout=${2:-}; shift 2 ;; -h|--help) help_text; exit 0 ;; -*) die "unknown option '$1'" ;; @@ -457,6 +464,8 @@ cmd_up() { case "$expect_host" in yes|no) ;; *) die "--expect-host takes yes or no" ;; esac [ "$host_line" != __default__ ] || { [ "$harness" = claude ] && host_line=claude || host_line=none; } [ -n "$model" ] || { [ "$harness" = claude ] && model=sonnet || model=openai-codex/gpt-6-luna; } + CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-} + case "$CLAUDE_DIR" in ''|/*) ;; *) die "CLAUDE_CONFIG_DIR must be an absolute path" ;; esac for tool in git tmux jq node python3 shasum "$harness"; do command -v "$tool" >/dev/null 2>&1 || die "$tool is required and was not found on PATH" done @@ -485,6 +494,7 @@ cmd_up() { echo "mate_id=$MATE_ID" echo "worker_id=$WORKER_ID" echo "pi_trust=$PI_TRUST_BEFORE" + echo "claude_config_dir=$CLAUDE_DIR" } > "$ROOT/$RECORD_NAME" echo "lab: $ROOT (tear down with: $0 down $ROOT)" @@ -516,7 +526,7 @@ cmd_up() { primary=(pi --approve --session-dir "$ROOT/pi-sessions" --model "$model" --thinking "$effort") fi lab_tmux new-window -d -t firstmate: -n main -c "$LAB" \ - env FM_HOME="$LAB" CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false ${extra_env[@]+"${extra_env[@]}"} "${primary[@]}" \ + env FM_HOME="$LAB" CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false "${primary[@]}" \ || die "cannot launch the lab primary" lab_tmux set-option -w -t "$(window_id main)" remain-on-exit on >/dev/null echo "primary: ${primary[*]}" @@ -541,11 +551,21 @@ cmd_up() { # ---- down ------------------------------------------------------------------- -# lab_pids: processes whose command line names the lab root, and every -# descendant of one (a child such as an engine turn need not name the root), -# never this script's own ancestry or descendants. +# lab_pids: processes whose command line names the lab root as a whole path +# (the root itself or a path under it, never a sibling that merely shares its +# prefix), and every descendant of one (a child such as an engine turn need not +# name the root), never this script's own ancestry or descendants. lab_pids() { - ps -axo pid=,ppid=,command= | awk -v self=$$ -v a="$ROOT" -v b="${ROOT#/private}" ' + ps -axo pid=,ppid=,command= | awk -v self=$$ -v a="$ROOT" -v b="${ROOT#/private}" -v lead=" =:\"'" -v trail="/ :\"'" ' + function names(s, r, start, i, pre, post) { + for (start = 1; (i = index(substr(s, start), r)) > 0; start = i + 1) { + i += start - 1 + pre = i > 1 ? substr(s, i - 1, 1) : "" + post = substr(s, i + length(r), 1) + if ((pre == "" || index(lead, pre)) && (post == "" || index(trail, post))) return 1 + } + return 0 + } { pid[NR]=$1; ppid[$1]=$2; line=$0; sub(/^ *[0-9]+ +[0-9]+ /, "", line); cmd[$1]=line } END { for (p = self; p > 1 && (p in ppid); p = ppid[p]) own[p] = 1 @@ -557,7 +577,7 @@ lab_pids() { for (i = 1; i <= NR; i++) { p = pid[i] if (p in own) continue - if (index(cmd[p], a) || index(cmd[p], b)) named[p] = 1 + if (names(cmd[p], a) || names(cmd[p], b)) named[p] = 1 } for (i = 1; i <= NR; i++) { p = pid[i] @@ -568,13 +588,19 @@ lab_pids() { } forget_claude_entries() { # remove every project entry at or under ROOT; prints the count - local store="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json" - [ -f "$store" ] || { echo 0; return 0; } + local store + store=$(claude_store) + [ -e "$store" ] || { echo 0; return 0; } node - "$store" "$ROOT" "${ROOT#/private}" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); -const [store, ...roots] = process.argv.slice(2); +const [link, ...roots] = process.argv.slice(2); +const store = fs.realpathSync(link); +const stat = fs.statSync(store); +if (!stat.isFile() || stat.uid !== process.getuid()) { + console.error(`error: ${store} is not a regular file this user owns`); process.exit(1); +} const inLab = (key) => roots.some((r) => key === r || key.startsWith(`${r}/`)); const fingerprint = (buf) => crypto.createHash("sha256").update(buf).digest("hex"); for (let attempt = 0; attempt < 3; attempt += 1) { @@ -614,6 +640,7 @@ NODE cmd_down() { load_lab "${1:-}" local rc=0 pids n=0 removed added id meta dir home_hash + local -a ids=() lab_tmux kill-server 2>/dev/null || true while pids=$(lab_pids) && [ -n "$pids" ] && [ "$n" -lt 20 ]; do # shellcheck disable=SC2086 # One pid per word. @@ -627,9 +654,12 @@ cmd_down() { # The second is scoped to this lab home for any task it spawned; the first is # removed only for the lab's own unique ids, since another home may share it. home_hash=$(printf '%s' "$LAB" | shasum -a 256 | awk '{print $1}') + ids=("$MATE_ID" "$WORKER_ID") for meta in "$LAB"/state/*.meta; do - [ -f "$meta" ] || continue - id=$(basename "$meta" .meta) + [ -f "$meta" ] && ids+=("$(basename "$meta" .meta)") + done + for id in "${ids[@]}"; do + [ -n "$id" ] || continue for dir in "/tmp/fm-$id+$home_hash" "/tmp/fm-$id"; do [ "$dir" != "/tmp/fm-$id" ] || [ "$id" = "$MATE_ID" ] || [ "$id" = "$WORKER_ID" ] || continue if [ -d "$dir" ] && [ ! -L "$dir" ] && [ -O "$dir" ]; then diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 8afd5dbb264..741ae203d2b 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -44,10 +44,11 @@ NONCE=abc12345 digest() { shasum -a 256 "$1" | awk '{print $1}'; } -# make_lab : a lab root in the shape up builds, with a live -# private tmux server. Every readiness input starts in its passing state. +# make_lab []: a lab root in the shape up +# builds, with a live private tmux server. Every readiness input starts in its +# passing state. make_lab() { - local root="$TMP_ROOT/$1" harness=$2 home tmux_dir lock_pid + local root="$TMP_ROOT/$1" harness=$2 claude_dir=${3:-} home tmux_dir lock_pid home="$root/home" mkdir -p "$root" "$ROOT/bin/fm-lab-home.sh" create "$home" >/dev/null || fail "lab home create" @@ -72,6 +73,7 @@ make_lab() { echo "mate_id=$MATE_ID" echo "worker_id=$WORKER_ID" echo "pi_trust=$(digest "$HOME/.pi/agent/trust.json")" + echo "claude_config_dir=$claude_dir" echo "tmux_dir=$tmux_dir" } > "$root/.fm-live-lab" @@ -93,7 +95,7 @@ make_lab() { '{"seq":2,"epoch":2,"key":"k","id":"b","tag":"main","text":"LABREADY"}' > "$home/state/.host-mirror.jsonl" write_pi_markers "$home" "$lock_pid" [ "$harness" = claude ] && node -e 'const fs=require("node:fs");const [s,h,r]=process.argv.slice(1);fs.writeFileSync(s,JSON.stringify({keep:1,projects:{[h]:{hasTrustDialogAccepted:true},[r+"/mate"]:{hasTrustDialogAccepted:true},"/elsewhere/project":{hasTrustDialogAccepted:true}}},null,2)+"\n")' \ - "$HOME/.claude.json" "$home" "$root" + "${claude_dir:-$HOME}/.claude.json" "$home" "$root" printf '%s\n' "$root" } @@ -122,7 +124,7 @@ start_watcher() { # : a live process holding a matching watcher lock } write_pi_markers() { # - local home=$1 pid=$2 v + local home=$1 pid=$2 v() { FM_HOME="$home" bash -c '. "$1/bin/fm-wake-lib.sh"; fm_pi_extension_version "$1/.pi/extensions/$2"' _ "$home" "$1"; } printf '%s\n%s\ngeneration=1 phase=active\n' "$(v fm-primary-pi-watch.ts)" "$pid" > "$home/state/.pi-watch-extension-loaded" printf '%s\n%s\n' "$(v fm-primary-turnend-guard.ts)" "$pid" > "$home/state/.pi-turnend-extension-loaded" @@ -287,14 +289,25 @@ printf 'sleep 600\n' > "$C/stray.sh" bash "$C/stray.sh" & STRAY=$! until STRAY_CHILD=$(pgrep -P "$STRAY" sleep); do sleep 0.1; done +# A sibling lab root that shares this root as a string prefix is not this lab. +mkdir -p "${C}2" +printf 'sleep 600\n' > "${C}2/stray.sh" +bash "${C}2/stray.sh" 2>/dev/null & +SIBLING=$! +printf '%s\n' "$SIBLING" >> "$TMP_ROOT/pids" +# The worker spawn failed after keeping its task temp dirs, before its meta. +rm -f "$CH/state/$WORKER_ID.meta" C_TMUX=$(sed -n 's/^tmux_dir=//p' "$C/.fm-live-lab") out=$("$LIVE_LAB" down "$C" 2>&1) expect_code 0 "$?" "down of a clean Claude lab succeeds: $out" ! kill -0 "$STRAY" 2>/dev/null || fail "down stops processes that name the lab root" ! kill -0 "$STRAY_CHILD" 2>/dev/null || fail "down stops their descendants, which need not name the root" assert_absent "$C" "down removes the lab root" +kill -0 "$SIBLING" 2>/dev/null || fail "down leaves a sibling root's process running" +pkill -P "$SIBLING" 2>/dev/null +kill "$SIBLING" 2>/dev/null assert_absent "$C_TMUX" "down removes the private tmux directory" -assert_absent "/tmp/fm-$WORKER_ID" "down removes the worker's task temp dir" +assert_absent "/tmp/fm-$WORKER_ID" "down removes the worker's task temp dir, even without its meta" assert_absent "/tmp/fm-$MATE_ID" "down removes the mate's task temp dir" assert_absent "/tmp/fm-$WORKER_ID+$C_HASH" "down removes the worker's launch dir" assert_absent "/tmp/fm-$OTHER_ID+$C_HASH" "down removes a lab-spawned task's launch dir scoped to the lab home" @@ -304,6 +317,25 @@ assert_equals '[1,["/elsewhere/project"]]' "$kept" "down removes exactly the lab assert_contains "$out" "removed: 2 Claude project entries" "down reports the removed entries" pass "down stops the lab, removes its trust entries and task temp dirs, and keeps everything else" +# The Claude store up selected is the one check and down use, even from a later +# shell with another CLAUDE_CONFIG_DIR, and a symlinked store stays a symlink. +SC="$TMP_ROOT/claude-config" +mkdir -p "$SC" +S=$(make_lab s claude "$SC") +mv "$SC/.claude.json" "$TMP_ROOT/claude-store-target.json" +ln -s "$TMP_ROOT/claude-store-target.json" "$SC/.claude.json" +HOME_STORE_BEFORE=$(digest "$HOME/.claude.json") +CHECK_OUT=$(CLAUDE_CONFIG_DIR="$TMP_ROOT/other-config" "$LIVE_LAB" check "$S" 2>&1) +assert_contains "$CHECK_OUT" "ok trust: $S/home is trusted in the Claude store" "check reads the recorded store" +out=$(CLAUDE_CONFIG_DIR="$TMP_ROOT/other-config" "$LIVE_LAB" down "$S" 2>&1) +expect_code 0 "$?" "down of a lab on a configured store succeeds: $out" +assert_contains "$out" "removed: 2 Claude project entries" "down removes the entries from the recorded store" +[ -L "$SC/.claude.json" ] || fail "down keeps a symlinked Claude store a symlink" +kept=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(JSON.stringify([j.keep,Object.keys(j.projects).sort()]))' "$TMP_ROOT/claude-store-target.json") +assert_equals '[1,["/elsewhere/project"]]' "$kept" "down rewrites the symlink's target" +assert_equals "$HOME_STORE_BEFORE" "$(digest "$HOME/.claude.json")" "down leaves the default store alone" +pass "check and down use the recorded Claude store and keep a symlinked store linked" + printf '{"trusted":["/somewhere"]}\n' > "$HOME/.pi/agent/trust.json" run_check "$P" assert_contains "$CHECK_OUT" "fail trust: the Pi trust store changed since up began" "a written Pi trust store is caught" From 45785881ee87f05b6445963b0850507f6143ac14 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 11:19:56 -0700 Subject: [PATCH 07/19] no-mistakes(review): Record selected user stores at up for check and down --- bin/fm-live-lab.sh | 24 ++++++++++++++---------- tests/fm-live-lab.test.sh | 13 +++++++++---- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 357defa031e..df8605960fc 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -44,9 +44,10 @@ # never lands in ~/.treehouse, and DISABLE_AUTOUPDATER=1 # keeps Claude Code from replacing the shared binary under # a running lab, as every live run does (tests/lib.sh). -# A set CLAUDE_CONFIG_DIR (absolute) is recorded at up and -# passed to every lab process, so up, check, and down all -# use that one Claude store, even from a later shell. +# A set CLAUDE_CONFIG_DIR (absolute) is passed to every lab +# process. up records the Claude store, Pi trust store, and +# ~/.treehouse it selected, so check and down use those same +# paths even from a later shell with another HOME. # trust Claude: bin/fm-claude-trust.sh --lab-home for the primary # (the workspace-trust prompt wedged the first lab), and the # spawn's own registration for the mate and worker. Pi: @@ -154,10 +155,11 @@ load_lab() { # : refuse anything up did not build, then load its record WORKER_ID=$(rec_get "$ROOT" worker_id) PI_TRUST_BEFORE=$(rec_get "$ROOT" pi_trust) CLAUDE_DIR=$(rec_get "$ROOT" claude_config_dir) + CLAUDE_STORE=$(rec_get "$ROOT" claude_store) + PI_TRUST_STORE=$(rec_get "$ROOT" pi_trust_store) + TREEHOUSE_DIR=$(rec_get "$ROOT" treehouse_dir) } -claude_store() { echo "${CLAUDE_DIR:-$HOME}/.claude.json"; } - lab_tmux() { [ -n "${TMUX_DIR:-}" ] || return 1 env -u TMUX TMUX_TMPDIR="$TMUX_DIR" tmux "$@" @@ -244,7 +246,7 @@ check_trust() { return 0 fi if node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' \ - "$(claude_store)" "$LAB" 2>/dev/null; then + "$CLAUDE_STORE" "$LAB" 2>/dev/null; then echo "ok trust: $LAB is trusted in the Claude store" else echo "fail trust: $LAB has no registered Claude workspace trust" @@ -466,6 +468,7 @@ cmd_up() { [ -n "$model" ] || { [ "$harness" = claude ] && model=sonnet || model=openai-codex/gpt-6-luna; } CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-} case "$CLAUDE_DIR" in ''|/*) ;; *) die "CLAUDE_CONFIG_DIR must be an absolute path" ;; esac + CLAUDE_STORE="${CLAUDE_DIR:-$HOME}/.claude.json" for tool in git tmux jq node python3 shasum "$harness"; do command -v "$tool" >/dev/null 2>&1 || die "$tool is required and was not found on PATH" done @@ -495,6 +498,9 @@ cmd_up() { echo "worker_id=$WORKER_ID" echo "pi_trust=$PI_TRUST_BEFORE" echo "claude_config_dir=$CLAUDE_DIR" + echo "claude_store=$CLAUDE_STORE" + echo "pi_trust_store=$PI_TRUST_STORE" + echo "treehouse_dir=$TREEHOUSE_DIR" } > "$ROOT/$RECORD_NAME" echo "lab: $ROOT (tear down with: $0 down $ROOT)" @@ -588,10 +594,8 @@ lab_pids() { } forget_claude_entries() { # remove every project entry at or under ROOT; prints the count - local store - store=$(claude_store) - [ -e "$store" ] || { echo 0; return 0; } - node - "$store" "$ROOT" "${ROOT#/private}" <<'NODE' + [ -e "$CLAUDE_STORE" ] || { echo 0; return 0; } + node - "$CLAUDE_STORE" "$ROOT" "${ROOT#/private}" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 741ae203d2b..158ad86d229 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -74,6 +74,9 @@ make_lab() { echo "worker_id=$WORKER_ID" echo "pi_trust=$(digest "$HOME/.pi/agent/trust.json")" echo "claude_config_dir=$claude_dir" + echo "claude_store=${claude_dir:-$HOME}/.claude.json" + echo "pi_trust_store=$HOME/.pi/agent/trust.json" + echo "treehouse_dir=$HOME/.treehouse" echo "tmux_dir=$tmux_dir" } > "$root/.fm-live-lab" @@ -249,8 +252,10 @@ mkdir "$HOME/.treehouse/notes-leaked" run_check "$C" assert_contains "$CHECK_OUT" "fail treehouse: new ~/.treehouse entries: notes-leaked" "treehouse names the leaked pool" rmdir "$HOME/.treehouse/notes-leaked" -run_check "$C" -expect_code 0 "$CHECK_RC" "the Claude lab is ready again after every restore: $CHECK_OUT" +LATER_HOME="$TMP_ROOT/later-home" +mkdir -p "$LATER_HOME" +CHECK_OUT=$(HOME="$LATER_HOME" "$LIVE_LAB" check "$C" 2>&1) +expect_code 0 "$?" "the Claude lab is ready again after every restore, even from a shell with another HOME: $CHECK_OUT" pass "treehouse fails when a pool lands in ~/.treehouse" # ---- Pi lab: extensions and the session-only trust store ------------------- @@ -298,8 +303,8 @@ printf '%s\n' "$SIBLING" >> "$TMP_ROOT/pids" # The worker spawn failed after keeping its task temp dirs, before its meta. rm -f "$CH/state/$WORKER_ID.meta" C_TMUX=$(sed -n 's/^tmux_dir=//p' "$C/.fm-live-lab") -out=$("$LIVE_LAB" down "$C" 2>&1) -expect_code 0 "$?" "down of a clean Claude lab succeeds: $out" +out=$(HOME="$LATER_HOME" "$LIVE_LAB" down "$C" 2>&1) +expect_code 0 "$?" "down of a clean Claude lab succeeds from a shell with another HOME: $out" ! kill -0 "$STRAY" 2>/dev/null || fail "down stops processes that name the lab root" ! kill -0 "$STRAY_CHILD" 2>/dev/null || fail "down stops their descendants, which need not name the root" assert_absent "$C" "down removes the lab root" From caa0e0a4e3d944b069a11f0c980ad4c0f523d7e3 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 11:30:16 -0700 Subject: [PATCH 08/19] no-mistakes(document): Clarify live lab documentation and remove stale narratives --- bin/fm-claude-trust.sh | 14 +++++++------- bin/fm-live-lab.sh | 25 +++++++++---------------- docs/scripts.md | 2 +- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/bin/fm-claude-trust.sh b/bin/fm-claude-trust.sh index abd26e6b034..8fc3fb6b88a 100755 --- a/bin/fm-claude-trust.sh +++ b/bin/fm-claude-trust.sh @@ -148,10 +148,10 @@ # are never written there. # # LAB-HOME MODE. A disposable lab primary (bin/fm-live-lab.sh) launches in a lab -# home that is neither a task worktree nor a seeded secondmate home, and its -# trust dialog was a recorded lab miss. The evidence is again structural: the -# home must carry bin/fm-lab-home.sh's marker (a regular file this user owns, -# never a symlink, holding the token bin/fm-gate-refuse-lib.sh owns), hold +# home that is neither a task worktree nor a seeded secondmate home. +# The evidence is structural: the home must carry bin/fm-lab-home.sh's marker +# (a regular file this user owns, never a symlink, holding the token +# bin/fm-gate-refuse-lib.sh owns), hold # AGENTS.md and bin/, and be a primary git checkout whose top level is exactly # the argument, because Claude Code keys the launch to that root. It is # trust-only for the same reason as a secondmate home, and bin/fm-live-lab.sh @@ -163,9 +163,9 @@ # file this uid owns; every unrelated key and project entry is preserved, and # both entries land in one atomic replacement. In secondmate-home and lab-home # mode: the single projects entry for the registered home path, same store, -# same atomic replacement. fm-spawn.sh forwards CLAUDE_CONFIG_DIR onto the claude launch -# verbatim rather than resolving it, and the pane starts in the registered -# directory, so only an absolute value names the same store on both sides; a +# same atomic replacement. fm-spawn.sh forwards CLAUDE_CONFIG_DIR onto the +# claude launch verbatim rather than resolving it, and the pane starts in the +# registered directory, so only an absolute value names the same store on both sides; a # relative one is refused below rather than guessed at. set -u # Path resolution here must answer from the filesystem, never from the caller's diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index df8605960fc..c62d9873b16 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -23,23 +23,20 @@ # lab primary, by default; mate and worker name the lab's own tasks). pane # prints a window's recent scrollback. down stops every lab process, removes the # lab's Claude trust entries by one atomic replace, removes , and exits -# non-zero if anything outside the lab changed. +# non-zero if the recorded Pi trust store or ~/.treehouse gained changes. # -# What up builds, and the recorded lab miss each part closes: +# What up builds: # home/ the lab main home: bin/fm-lab-home.sh create, then the # committed tree of (default: HEAD of the # checkout this script runs from) checked out as a genuine -# primary checkout, with FM_HOME at its root. A primary -# launched from a linked worktree or from an operational home -# that is not the tree's root had no dialog mirror. +# primary checkout, with FM_HOME at its root. # config/ backend tmux, Claude crews and second mates, and # supervision-host (default claude on Claude, absent # on Pi; none leaves the file absent). # tmux server private, through the lab home's bin/fm-lab-home.sh # tmux-dir, with no user tmux config (its plugins never run # in a lab), started from an empty environment so no inherited -# TMUX, Herdr, or Pi marker reaches a lab process (a probe -# that inherited the caller's Pi marker was discarded). +# TMUX, Herdr, or Pi marker reaches a lab process. # TREEHOUSE_ROOT points into , so a worker's pool # never lands in ~/.treehouse, and DISABLE_AUTOUPDATER=1 # keeps Claude Code from replacing the shared binary under @@ -49,12 +46,11 @@ # ~/.treehouse it selected, so check and down use those same # paths even from a later shell with another HOME. # trust Claude: bin/fm-claude-trust.sh --lab-home for the primary -# (the workspace-trust prompt wedged the first lab), and the -# spawn's own registration for the mate and worker. Pi: +# and the spawn's own registration for the mate and worker. Pi: # --approve, which trusts project-local files for this run # only, so the Pi trust store is never written and all of -# .pi/extensions loads (one Pi lab lacked the branch -# extension); sessions stay under /pi-sessions. +# .pi/extensions loads; sessions stay under +# /pi-sessions. # task ids lab-mate and lab-worker, unique per lab, # because a spawn keeps a task temp dir at /tmp/fm- # that a fixed id would share with other labs and tasks. @@ -68,8 +64,7 @@ # it to let the worker finish. # primary window main: claude --setting-sources project,local # (default sonnet, medium, permission mode auto) or pi -# (default openai-codex/gpt-6-luna, medium; three older -# Codex models were rejected by the account), launched +# (default openai-codex/gpt-6-luna, medium), launched # after the mate and worker so its first turn end arms # supervision. up then sends one harmless probe prompt. # @@ -82,9 +77,7 @@ # Pi: the Pi trust store is byte-identical to before up. # mirror Claude with --expect-host yes: the tree's # fm-host-mirror.sh verified claude and fm-host-mirror.sh check -# pass, and the mirror holds a captain and a main entry (the -# feed was once not wired, and once the check existed only on -# an unmerged branch; a missing subcommand fails here by name). +# pass, and the mirror holds a captain and a main entry. # extensions Pi: the watcher, turn-end guard, and branch extensions are # loaded by the process holding the lab session lock, at the # current on-disk builds. diff --git a/docs/scripts.md b/docs/scripts.md index 07e81904e70..56b4c08af11 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -39,7 +39,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-herdr-lab-viewer.py` | The pty engine behind `fm-herdr-lab.sh viewer`: one real foreground Herdr client on a non-zero window grid | | `fm-lab-home.sh` | Mint disposable lab homes and manage their isolated tmux socket directories | -| `fm-live-lab.sh` | Stand up, readiness-check, drive, and cleanly tear down a disposable live supervision lab (Claude or Pi primary, optional seeded second mate and gated worker) | +| `fm-live-lab.sh` | Build and operate a disposable live supervision lab; see its header for usage and readiness contract | | `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks | | `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees | | `fm-herdr-ci-cleanup.sh` | Snapshot and tear down only job-owned `fm-lab-*` sessions in the Herdr CI lane | From b3367f0b839d9001f2c63be082dab83589a590c2 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 12:18:59 -0700 Subject: [PATCH 09/19] no-mistakes(ci): Fixed the CI failure by checking for an existing lab root before looking up the harness executable. The affected behavioral test and shell syntax check pass; the refusal also works with Claude absent from PATH --- bin/fm-live-lab.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index c62d9873b16..140e37e2457 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -462,6 +462,7 @@ cmd_up() { CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-} case "$CLAUDE_DIR" in ''|/*) ;; *) die "CLAUDE_CONFIG_DIR must be an absolute path" ;; esac CLAUDE_STORE="${CLAUDE_DIR:-$HOME}/.claude.json" + [ -z "$root" ] || [ ! -e "$root" ] || die "refusing '$root': a lab root must not exist yet" for tool in git tmux jq node python3 shasum "$harness"; do command -v "$tool" >/dev/null 2>&1 || die "$tool is required and was not found on PATH" done @@ -469,7 +470,6 @@ cmd_up() { if [ -z "$root" ]; then root=$(mktemp -d /tmp/fmlab.XXXXXX) || die "cannot create a lab root" else - [ ! -e "$root" ] || die "refusing '$root': a lab root must not exist yet" mkdir -p "$root" || die "cannot create '$root'" fi ROOT=$(real_dir "$root") From e2dc0e56f2af3d04e53000477f12644b4b4f3178 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 13:01:43 -0700 Subject: [PATCH 10/19] no-mistakes(ci): Fixed all four Greptile findings: teardown signals only recorded lab processes and their descendants; the worker gate is in its granted task directory and its path is exposed; readiness uses current crew state; and mate and worker IDs use 12 nonce hex digits. The CLI behavior tests pass, as do shell syntax, ShellCheck, and diff checks. The Claude no-host path is unchanged --- bin/fm-live-lab.sh | 93 +++++++++++++++++++-------------------- tests/fm-live-lab.test.sh | 54 ++++++++++++++++++----- 2 files changed, 89 insertions(+), 58 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 140e37e2457..76acc9bc833 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -60,8 +60,8 @@ # worker --worker: a lab project notes with a lab-private origin # (local-only +yolo), a scaffolded brief, a backlog item, and # a real bin/fm-spawn.sh worker that parks on -# /gates/ until that file exists; touch -# it to let the worker finish. +# /home/data//gate until that file +# exists; touch it to let the worker finish. # primary window main: claude --setting-sources project,local # (default sonnet, medium, permission mode auto) or pi # (default openai-codex/gpt-6-luna, medium), launched @@ -88,12 +88,11 @@ # (skipped on an empty fleet). # mate --mate: its window is alive and its own session lock names a # live process, so it got past trust into its charter. -# worker --worker: the worker declared its gate wait in its status. +# worker --worker: its current crew state is paused on the gate. # treehouse ~/.treehouse gained no entry since up began. # # down refuses any path without the lab record up writes. It kills only the -# lab's private tmux server, the processes whose command line names -# as a whole path (never a sibling such as 2), and their descendants; +# lab's recorded private tmux server and launch pane PIDs, and their descendants; # runs bin/fm-lab-home.sh teardown; removes the task temp and launch dirs the # lab's spawns kept under /tmp, including a failed spawn's; removes every # project entry at or under from the recorded Claude store, following @@ -146,6 +145,7 @@ load_lab() { # : refuse anything up did not build, then load its record NONCE=$(rec_get "$ROOT" nonce) MATE_ID=$(rec_get "$ROOT" mate_id) WORKER_ID=$(rec_get "$ROOT" worker_id) + GATE=$(rec_get "$ROOT" gate) PI_TRUST_BEFORE=$(rec_get "$ROOT" pi_trust) CLAUDE_DIR=$(rec_get "$ROOT" claude_config_dir) CLAUDE_STORE=$(rec_get "$ROOT" claude_store) @@ -314,10 +314,14 @@ check_mate() { } check_worker() { + local state window_alive worker || { echo "fail worker: the $WORKER_ID window is not running"; return 1; } - grep -E '^paused' "$LAB/state/$WORKER_ID.status" 2>/dev/null | grep -Fq "$ROOT/gates/$WORKER_ID" \ - || { echo "fail worker: the worker has not declared its gate wait yet"; return 1; } - echo "ok worker: $WORKER_ID parked on $ROOT/gates/$WORKER_ID" + state=$(cd "$LAB" && lab_run FM_HOME="$LAB" FM_CREW_STATE_NO_FORGE=1 "$LAB/bin/fm-crew-state.sh" "$WORKER_ID" 2>/dev/null) + case "$state" in + "state: paused · "*"$GATE"*) ;; + *) echo "fail worker: the worker is not currently parked on $GATE (${state:-no state})"; return 1 ;; + esac + echo "ok worker: $WORKER_ID parked on $GATE" } check_treehouse() { @@ -407,8 +411,7 @@ MD } spawn_worker() { - local brief="$LAB/data/$WORKER_ID/brief.md" gate="$ROOT/gates/$WORKER_ID" - mkdir -p "$ROOT/gates" + local brief="$LAB/data/$WORKER_ID/brief.md" gate="$GATE" make_notes_project || return 1 (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-brief.sh" "$WORKER_ID" notes --mode local-only) >/dev/null || return 1 TASK_TEXT="Lab gated worker for a live supervision lab. Add count_notes(), which returns how many notes are stored, to notes/__init__.py with a unit test, but only after the gate file $gate exists." \ @@ -475,8 +478,9 @@ cmd_up() { ROOT=$(real_dir "$root") LAB="$ROOT/home" HARNESS=$harness EXPECT_HOST=$expect_host WANT_MATE=$mate WANT_WORKER=$worker - NONCE=$(od -An -N4 -tx4 /dev/urandom | tr -d ' \n') - MATE_ID="lab${NONCE:0:6}-mate" WORKER_ID="lab${NONCE:0:6}-worker" + NONCE=$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n') + MATE_ID="lab${NONCE:0:12}-mate" WORKER_ID="lab${NONCE:0:12}-worker" + GATE="$LAB/data/$WORKER_ID/gate" PI_TRUST_BEFORE=$(digest "$PI_TRUST_STORE") treehouse_listing | sort > "$ROOT/.treehouse-before" { @@ -489,6 +493,7 @@ cmd_up() { echo "nonce=$NONCE" echo "mate_id=$MATE_ID" echo "worker_id=$WORKER_ID" + echo "gate=$GATE" echo "pi_trust=$PI_TRUST_BEFORE" echo "claude_config_dir=$CLAUDE_DIR" echo "claude_store=$CLAUDE_STORE" @@ -513,9 +518,17 @@ cmd_up() { TMUX_DIR=$("$LAB_HOME_HELPER" tmux-dir "$LAB") || die "cannot create the private tmux directory" echo "tmux_dir=$TMUX_DIR" >> "$ROOT/$RECORD_NAME" lab_run tmux -f /dev/null new-session -d -s firstmate -n lab -x 220 -y 60 -c "$ROOT" || die "cannot start the lab tmux server" + echo "launch_pid=$(lab_tmux display-message -p '#{pid}')" >> "$ROOT/$RECORD_NAME" - if [ "$mate" = yes ]; then spawn_mate || die "cannot seed and launch the second mate"; fi - if [ "$worker" = yes ]; then spawn_worker || die "cannot launch the gated worker"; fi + if [ "$mate" = yes ]; then + spawn_mate || die "cannot seed and launch the second mate" + echo "launch_pid=$(window_field mate '#{pane_pid}')" >> "$ROOT/$RECORD_NAME" + fi + if [ "$worker" = yes ]; then + spawn_worker || die "cannot launch the gated worker" + echo "launch_pid=$(window_field worker '#{pane_pid}')" >> "$ROOT/$RECORD_NAME" + echo "gate: $GATE (touch to release the worker)" + fi local -a primary=() if [ "$harness" = claude ]; then @@ -528,6 +541,7 @@ cmd_up() { env FM_HOME="$LAB" CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false "${primary[@]}" \ || die "cannot launch the lab primary" lab_tmux set-option -w -t "$(window_id main)" remain-on-exit on >/dev/null + echo "launch_pid=$(window_field main '#{pane_pid}')" >> "$ROOT/$RECORD_NAME" echo "primary: ${primary[*]}" local deadline=$(( $(date +%s) + 180 )) @@ -550,38 +564,23 @@ cmd_up() { # ---- down ------------------------------------------------------------------- -# lab_pids: processes whose command line names the lab root as a whole path -# (the root itself or a path under it, never a sibling that merely shares its -# prefix), and every descendant of one (a child such as an engine turn need not -# name the root), never this script's own ancestry or descendants. +# lab_pids: only recorded launch PIDs and their descendants. Resolve the tree +# before killing tmux, while pane children still have their original parents. lab_pids() { - ps -axo pid=,ppid=,command= | awk -v self=$$ -v a="$ROOT" -v b="${ROOT#/private}" -v lead=" =:\"'" -v trail="/ :\"'" ' - function names(s, r, start, i, pre, post) { - for (start = 1; (i = index(substr(s, start), r)) > 0; start = i + 1) { - i += start - 1 - pre = i > 1 ? substr(s, i - 1, 1) : "" - post = substr(s, i + length(r), 1) - if ((pre == "" || index(lead, pre)) && (post == "" || index(trail, post))) return 1 + ps -axo pid=,ppid= | awk -v record="$ROOT/$RECORD_NAME" ' + BEGIN { + while ((getline line < record) > 0) { + if (line ~ /^launch_pid=[0-9]+$/) { sub(/^launch_pid=/, "", line); roots[line] = 1 } } - return 0 + close(record) } - { pid[NR]=$1; ppid[$1]=$2; line=$0; sub(/^ *[0-9]+ +[0-9]+ /, "", line); cmd[$1]=line } + { pid[NR]=$1; ppid[$1]=$2 } END { - for (p = self; p > 1 && (p in ppid); p = ppid[p]) own[p] = 1 for (i = 1; i <= NR; i++) { p = pid[i] - for (q = ppid[p]; q > 1 && (q in ppid); q = ppid[q]) if (q == self) break - if (q == self) own[p] = 1 - } - for (i = 1; i <= NR; i++) { - p = pid[i] - if (p in own) continue - if (names(cmd[p], a) || names(cmd[p], b)) named[p] = 1 - } - for (i = 1; i <= NR; i++) { - p = pid[i] - if (p in own) continue - for (q = p; q > 1 && (q in ppid); q = ppid[q]) if (q in named) { print p; break } + for (q = p; q > 1 && (q in ppid); q = ppid[q]) { + if (q in roots) { print p; break } + } } }' } @@ -636,16 +635,14 @@ NODE cmd_down() { load_lab "${1:-}" - local rc=0 pids n=0 removed added id meta dir home_hash + local rc=0 pids removed added id meta dir home_hash local -a ids=() - lab_tmux kill-server 2>/dev/null || true - while pids=$(lab_pids) && [ -n "$pids" ] && [ "$n" -lt 20 ]; do - # shellcheck disable=SC2086 # One pid per word. - if [ "$n" -lt 10 ]; then kill $pids 2>/dev/null; else kill -9 $pids 2>/dev/null; fi - n=$((n + 1)); sleep 1 - done pids=$(lab_pids) - [ -z "$pids" ] || die "refusing to remove the lab: processes still name it: $(printf '%s' "$pids" | tr '\n' ' ')" + lab_tmux kill-server 2>/dev/null || true + if [ -n "$pids" ]; then + # shellcheck disable=SC2086 # One pid per word, captured before tmux reparents panes. + kill $pids 2>/dev/null || true + fi echo "stopped: lab tmux server and lab processes" # A spawn keeps /tmp/fm- and /tmp/fm-+. # The second is scoped to this lab home for any task it spawned; the first is diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 158ad86d229..79034364a4c 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -54,7 +54,7 @@ make_lab() { "$ROOT/bin/fm-lab-home.sh" create "$home" >/dev/null || fail "lab home create" cp -R "$ROOT/bin" "$home/bin" cp "$ROOT/AGENTS.md" "$home/AGENTS.md" - mkdir -p "$home/.pi" "$root/mate/state" "$root/gates" "$root/treehouse" + mkdir -p "$home/.pi" "$home/data/$WORKER_ID" "$root/mate/state" "$root/treehouse" cp -R "$ROOT/.pi/extensions" "$home/.pi/extensions" git -C "$home" init -q -b main git -C "$home" add -A bin AGENTS.md .pi @@ -72,6 +72,7 @@ make_lab() { echo "nonce=$NONCE" echo "mate_id=$MATE_ID" echo "worker_id=$WORKER_ID" + echo "gate=$home/data/$WORKER_ID/gate" echo "pi_trust=$(digest "$HOME/.pi/agent/trust.json")" echo "claude_config_dir=$claude_dir" echo "claude_store=${claude_dir:-$HOME}/.claude.json" @@ -81,12 +82,14 @@ make_lab() { } > "$root/.fm-live-lab" lab_tmux "$root" new-session -d -s firstmate -n lab -c "$root" 'exec sleep 600' + printf 'launch_pid=%s\n' "$(lab_tmux "$root" display-message -p '#{pid}')" >> "$root/.fm-live-lab" lab_tmux "$root" new-window -d -t firstmate: -n main -c "$home" "printf 'LABREADY-$NONCE\n'; exec sleep 600" lab_tmux "$root" new-window -d -t firstmate: -n "fm-$MATE_ID" -c "$root/mate" 'exec sleep 600' lab_tmux "$root" new-window -d -t firstmate: -n "fm-$WORKER_ID" -c "$root" 'exec sleep 600' fm_write_meta "$home/state/$MATE_ID.meta" "window=firstmate:fm-$MATE_ID" "tasktmp=/tmp/fm-$MATE_ID" - fm_write_meta "$home/state/$WORKER_ID.meta" "window=firstmate:fm-$WORKER_ID" "tasktmp=/tmp/fm-$WORKER_ID" - printf 'paused [at=1]: waiting on gate file %s to exist\n' "$root/gates/$WORKER_ID" > "$home/state/$WORKER_ID.status" + fm_write_meta "$home/state/$WORKER_ID.meta" "window=firstmate:fm-$WORKER_ID" "worktree=$home/projects/notes" "kind=secondmate" "tasktmp=/tmp/fm-$WORKER_ID" + mkdir -p "$home/projects/notes" + printf 'paused [at=1]: waiting on gate file %s to exist\n' "$home/data/$WORKER_ID/gate" > "$home/state/$WORKER_ID.status" lock_pid=$(lab_tmux "$root" display-message -p -t firstmate:=main '#{pane_pid}') printf '%s\n' "$lock_pid" > "$home/state/.lock" @@ -240,12 +243,20 @@ assert_contains "$CHECK_OUT" "fail mate: the mate holds no session lock yet" "ma lab_tmux "$C" display-message -p -t "firstmate:=fm-$MATE_ID" '#{pane_pid}' > "$C/mate/state/.lock" pass "mate fails when its window is gone or it never reached its charter" -# worker: it must have declared its gate wait. +# The current-state reader, not an old event, establishes the gate wait. +GATE="$CH/data/$WORKER_ID/gate" +assert_contains "$(sed -n 's/^gate=//p' "$C/.fm-live-lab")" "$CH/data/$WORKER_ID/" "operator can find the gate in the worker's task directory" : > "$CH/state/$WORKER_ID.status" run_check "$C" -assert_contains "$CHECK_OUT" "fail worker: the worker has not declared its gate wait yet" "worker needs its paused line" -printf 'paused [at=1]: waiting on gate file %s to exist\n' "$C/gates/$WORKER_ID" > "$CH/state/$WORKER_ID.status" -pass "worker fails until the worker parks on its gate" +assert_contains "$CHECK_OUT" "fail worker: the worker is not currently parked" "worker needs a current pause" +printf 'paused [at=1]: waiting on gate file %s to exist\n' "$GATE" > "$CH/state/$WORKER_ID.status" +printf 'working [at=2]: resumed\n' >> "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "fail worker: the worker is not currently parked" "stale paused event cannot pass readiness" +printf 'paused [at=3]: waiting on gate file %s to exist\n' "$GATE" >> "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "ok worker: $WORKER_ID parked on $GATE" "current pause passes readiness" +pass "worker readiness follows current crew state and the recorded accessible gate" # treehouse: a worker pool must land inside the lab, never in ~/.treehouse. mkdir "$HOME/.treehouse/notes-leaked" @@ -290,10 +301,17 @@ C_HASH=$(printf '%s' "$CH" | shasum -a 256 | awk '{print $1}') OTHER_ID="labt$$-other" fm_write_meta "$CH/state/$OTHER_ID.meta" "window=firstmate:fm-$OTHER_ID" "tasktmp=/tmp/fm-$OTHER_ID" mkdir -p "/tmp/fm-$WORKER_ID/gotmp" "/tmp/fm-$MATE_ID" "/tmp/fm-$WORKER_ID+$C_HASH" "/tmp/fm-$OTHER_ID+$C_HASH" "/tmp/fm-$OTHER_ID" +# An outsider opening a lab path is not owned by the lab. printf 'sleep 600\n' > "$C/stray.sh" bash "$C/stray.sh" & STRAY=$! +printf '%s\n' "$STRAY" >> "$TMP_ROOT/pids" until STRAY_CHILD=$(pgrep -P "$STRAY" sleep); do sleep 0.1; done +printf '%s\n' "$STRAY_CHILD" >> "$TMP_ROOT/pids" +# A launch-recorded process and its child must be stopped even when not in tmux. +sleep 600 & +OWNED=$! +printf 'launch_pid=%s\n' "$OWNED" >> "$C/.fm-live-lab" # A sibling lab root that shares this root as a string prefix is not this lab. mkdir -p "${C}2" printf 'sleep 600\n' > "${C}2/stray.sh" @@ -305,8 +323,9 @@ rm -f "$CH/state/$WORKER_ID.meta" C_TMUX=$(sed -n 's/^tmux_dir=//p' "$C/.fm-live-lab") out=$(HOME="$LATER_HOME" "$LIVE_LAB" down "$C" 2>&1) expect_code 0 "$?" "down of a clean Claude lab succeeds from a shell with another HOME: $out" -! kill -0 "$STRAY" 2>/dev/null || fail "down stops processes that name the lab root" -! kill -0 "$STRAY_CHILD" 2>/dev/null || fail "down stops their descendants, which need not name the root" +kill -0 "$STRAY" 2>/dev/null || fail "down leaves unrelated processes opening the lab path alone" +kill -0 "$STRAY_CHILD" 2>/dev/null || fail "down leaves their descendants alone" +! kill -0 "$OWNED" 2>/dev/null || fail "down stops recorded launch processes" assert_absent "$C" "down removes the lab root" kill -0 "$SIBLING" 2>/dev/null || fail "down leaves a sibling root's process running" pkill -P "$SIBLING" 2>/dev/null @@ -320,7 +339,8 @@ assert_present "/tmp/fm-$OTHER_ID" "down keeps a task temp dir another home coul kept=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(JSON.stringify([j.keep,Object.keys(j.projects).sort()]))' "$HOME/.claude.json") assert_equals '[1,["/elsewhere/project"]]' "$kept" "down removes exactly the lab's Claude project entries" assert_contains "$out" "removed: 2 Claude project entries" "down reports the removed entries" -pass "down stops the lab, removes its trust entries and task temp dirs, and keeps everything else" +kill "$STRAY" "$STRAY_CHILD" 2>/dev/null || true +pass "down stops only recorded lab processes, removes trust entries and task temp dirs" # The Claude store up selected is the one check and down use, even from a later # shell with another CLAUDE_CONFIG_DIR, and a symlinked store stays a symlink. @@ -366,6 +386,20 @@ expect_code 1 "$?" "up refuses an unsupported harness" assert_absent "$TMP_ROOT/new" "a refused harness creates nothing" pass "up refuses an existing root and an unsupported harness" +# up persists full-width, distinct task IDs even when checkout fails before +# launching a harness; down can still clean this partial lab. +PARTIAL="$TMP_ROOT/partial-lab" +out=$($LIVE_LAB up --harness claude --source "$TMP_ROOT/missing-origin" "$PARTIAL" 2>&1) +expect_code 1 "$?" "an unavailable source stops up before launch" +assert_present "$PARTIAL/.fm-live-lab" "up recorded its selected task IDs" +ids=$(awk -F= '/^(mate_id|worker_id)=/ {print $2}' "$PARTIAL/.fm-live-lab") +if ! printf '%s\n' "$ids" | grep -Eq '^lab[0-9a-f]{12}-(mate|worker)$'; then fail "task IDs need twelve nonce hex digits: $ids"; fi +assert_equals 2 "$(printf '%s\n' "$ids" | grep -Ec '^lab[0-9a-f]{12}-(mate|worker)$')" "both mate and worker use twelve nonce digits" +out=$($LIVE_LAB down "$PARTIAL" 2>&1) +expect_code 0 "$?" "down cleans a lab whose checkout failed: $out" +assert_absent "$PARTIAL" "partial lab removed" +pass "up gives both task IDs a long nonce and down cleans partial setup" + # ---- fm-claude-trust.sh --lab-home ------------------------------------------- T="$TMP_ROOT/trust" From ac6637d6dec2b9a031ef2363d6f3ddbfc1ae696e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 14:16:47 -0700 Subject: [PATCH 11/19] =?UTF-8?q?no-mistakes(ci):=20Fixed=20the=20CI=20tes?= =?UTF-8?q?t=E2=80=99s=20dependence=20on=20an=20installed=20Claude=20binar?= =?UTF-8?q?y=20by=20supplying=20a=20test-local=20stub.=20The=20full=20fm-l?= =?UTF-8?q?ive-lab=20test,=20shell=20syntax=20check,=20and=20diff=20check?= =?UTF-8?q?=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/fm-live-lab.test.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 79034364a4c..0eea4094b7b 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -389,7 +389,10 @@ pass "up refuses an existing root and an unsupported harness" # up persists full-width, distinct task IDs even when checkout fails before # launching a harness; down can still clean this partial lab. PARTIAL="$TMP_ROOT/partial-lab" -out=$($LIVE_LAB up --harness claude --source "$TMP_ROOT/missing-origin" "$PARTIAL" 2>&1) +mkdir -p "$TMP_ROOT/stub-bin" +printf '#!/bin/sh\nexit 99\n' > "$TMP_ROOT/stub-bin/claude" +chmod +x "$TMP_ROOT/stub-bin/claude" +out=$(PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --source "$TMP_ROOT/missing-origin" "$PARTIAL" 2>&1) expect_code 1 "$?" "an unavailable source stops up before launch" assert_present "$PARTIAL/.fm-live-lab" "up recorded its selected task IDs" ids=$(awk -F= '/^(mate_id|worker_id)=/ {print $2}' "$PARTIAL/.fm-live-lab") From 89023211cc9b2422215f465c9691ba99dfd9cd26 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 14:58:23 -0700 Subject: [PATCH 12/19] no-mistakes(ci): Fixed all three selected findings in bin/fm-live-lab.sh: down waits for recorded processes and escalates before cleanup, PID roots are checked against recorded start times, and Claude primary trust is rechecked after mate/worker readiness. Added behavioral tests in tests/fm-live-lab.test.sh. bin/fm-lint.sh and tests/fm-live-lab.test.sh pass --- bin/fm-live-lab.sh | 73 ++++++++++++++++++++++++------ tests/fm-live-lab.test.sh | 93 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 76acc9bc833..183170a556f 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -231,6 +231,11 @@ check_probe() { fi } +lab_trust_present() { + node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' \ + "$CLAUDE_STORE" "$LAB" 2>/dev/null +} + check_trust() { if [ "$HARNESS" = pi ]; then [ "$(digest "$PI_TRUST_STORE")" = "$PI_TRUST_BEFORE" ] \ @@ -238,8 +243,7 @@ check_trust() { echo "ok trust: Pi trust store unchanged (session-only --approve)" return 0 fi - if node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' \ - "$CLAUDE_STORE" "$LAB" 2>/dev/null; then + if lab_trust_present; then echo "ok trust: $LAB is trusted in the Claude store" else echo "fail trust: $LAB has no registered Claude workspace trust" @@ -518,21 +522,31 @@ cmd_up() { TMUX_DIR=$("$LAB_HOME_HELPER" tmux-dir "$LAB") || die "cannot create the private tmux directory" echo "tmux_dir=$TMUX_DIR" >> "$ROOT/$RECORD_NAME" lab_run tmux -f /dev/null new-session -d -s firstmate -n lab -x 220 -y 60 -c "$ROOT" || die "cannot start the lab tmux server" - echo "launch_pid=$(lab_tmux display-message -p '#{pid}')" >> "$ROOT/$RECORD_NAME" + record_launch_pid "$(lab_tmux display-message -p '#{pid}')" if [ "$mate" = yes ]; then spawn_mate || die "cannot seed and launch the second mate" - echo "launch_pid=$(window_field mate '#{pane_pid}')" >> "$ROOT/$RECORD_NAME" + record_launch_pid "$(window_field mate '#{pane_pid}')" fi if [ "$worker" = yes ]; then spawn_worker || die "cannot launch the gated worker" - echo "launch_pid=$(window_field worker '#{pane_pid}')" >> "$ROOT/$RECORD_NAME" + record_launch_pid "$(window_field worker '#{pane_pid}')" echo "gate: $GATE (touch to release the worker)" fi local -a primary=() if [ "$harness" = claude ]; then - lab_run "$CLAUDE_TRUST" --lab-home "$LAB" >/dev/null || die "cannot register Claude trust for the lab home" + local settle=$(( $(date +%s) + 300 )) attempt + until { [ "$mate" != yes ] || check_mate >/dev/null; } && { [ "$worker" != yes ] || check_worker >/dev/null; }; do + [ "$(date +%s)" -lt "$settle" ] || die "mate or worker did not become ready before Claude primary trust registration" + sleep 2 + done + for attempt in 1 2 3; do + lab_run "$CLAUDE_TRUST" --lab-home "$LAB" >/dev/null || die "cannot register Claude trust for the lab home" + sleep 1 + lab_trust_present && break + done + lab_trust_present || die "the lab home's Claude trust keeps disappearing from $CLAUDE_STORE" primary=(claude --setting-sources "project,local" --model "$model" --effort "$effort" --permission-mode auto) else primary=(pi --approve --session-dir "$ROOT/pi-sessions" --model "$model" --thinking "$effort") @@ -541,7 +555,7 @@ cmd_up() { env FM_HOME="$LAB" CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false "${primary[@]}" \ || die "cannot launch the lab primary" lab_tmux set-option -w -t "$(window_id main)" remain-on-exit on >/dev/null - echo "launch_pid=$(window_field main '#{pane_pid}')" >> "$ROOT/$RECORD_NAME" + record_launch_pid "$(window_field main '#{pane_pid}')" echo "primary: ${primary[*]}" local deadline=$(( $(date +%s) + 180 )) @@ -564,17 +578,31 @@ cmd_up() { # ---- down ------------------------------------------------------------------- -# lab_pids: only recorded launch PIDs and their descendants. Resolve the tree -# before killing tmux, while pane children still have their original parents. +record_launch_pid() { + local start + start=$(ps -o lstart= -p "$1" | awk '{$1=$1; print}') + [ -n "$start" ] || die "cannot record start time for lab process $1" + printf 'launch_pid=%s\nlaunch_start=%s\n' "$1" "$start" >> "$ROOT/$RECORD_NAME" +} + +# Resolve recorded roots only while their start times match, before tmux +# reparents their descendants. lab_pids() { - ps -axo pid=,ppid= | awk -v record="$ROOT/$RECORD_NAME" ' + ps -axo pid=,ppid=,lstart= | awk -v record="$ROOT/$RECORD_NAME" ' BEGIN { while ((getline line < record) > 0) { - if (line ~ /^launch_pid=[0-9]+$/) { sub(/^launch_pid=/, "", line); roots[line] = 1 } + if (line ~ /^launch_pid=[0-9]+$/) { sub(/^launch_pid=/, "", line); root=line } + else if (line ~ /^launch_start=/ && root != "") { + sub(/^launch_start=/, "", line); starts[root]=line; root="" + } } close(record) } - { pid[NR]=$1; ppid[$1]=$2 } + { + pid[NR]=$1; ppid[$1]=$2 + start=$3 " " $4 " " $5 " " $6 " " $7 + if ($1 in starts && start == starts[$1]) roots[$1]=1 + } END { for (i = 1; i <= NR; i++) { p = pid[i] @@ -585,6 +613,14 @@ lab_pids() { }' } +live_pids() { + local pid state + for pid in $1; do + state=$(ps -o stat= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') + case "$state" in ''|Z*) ;; *) echo "$pid" ;; esac + done +} + forget_claude_entries() { # remove every project entry at or under ROOT; prints the count [ -e "$CLAUDE_STORE" ] || { echo 0; return 0; } node - "$CLAUDE_STORE" "$ROOT" "${ROOT#/private}" <<'NODE' @@ -635,7 +671,7 @@ NODE cmd_down() { load_lab "${1:-}" - local rc=0 pids removed added id meta dir home_hash + local rc=0 pids survivors n removed added id meta dir home_hash local -a ids=() pids=$(lab_pids) lab_tmux kill-server 2>/dev/null || true @@ -643,6 +679,17 @@ cmd_down() { # shellcheck disable=SC2086 # One pid per word, captured before tmux reparents panes. kill $pids 2>/dev/null || true fi + for n in {1..40}; do + survivors=$(live_pids "$pids $(lab_pids)") + [ -n "$survivors" ] || break + if [ "$n" -eq 20 ]; then + # shellcheck disable=SC2086 # One pid per word. + kill -9 $survivors 2>/dev/null || true + fi + sleep 0.5 + done + survivors=$(live_pids "$pids $(lab_pids)") + [ -z "$survivors" ] || die "refusing to remove the lab: its processes did not exit: $(echo "$survivors" | tr '\n' ' ')" echo "stopped: lab tmux server and lab processes" # A spawn keeps /tmp/fm- and /tmp/fm-+. # The second is scoped to this lab home for any task it spawned; the first is diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 0eea4094b7b..ce1257cfda8 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -82,7 +82,7 @@ make_lab() { } > "$root/.fm-live-lab" lab_tmux "$root" new-session -d -s firstmate -n lab -c "$root" 'exec sleep 600' - printf 'launch_pid=%s\n' "$(lab_tmux "$root" display-message -p '#{pid}')" >> "$root/.fm-live-lab" + record_pid "$root" "$(lab_tmux "$root" display-message -p '#{pid}')" lab_tmux "$root" new-window -d -t firstmate: -n main -c "$home" "printf 'LABREADY-$NONCE\n'; exec sleep 600" lab_tmux "$root" new-window -d -t firstmate: -n "fm-$MATE_ID" -c "$root/mate" 'exec sleep 600' lab_tmux "$root" new-window -d -t firstmate: -n "fm-$WORKER_ID" -c "$root" 'exec sleep 600' @@ -105,6 +105,10 @@ make_lab() { printf '%s\n' "$root" } +record_pid() { # + printf 'launch_pid=%s\nlaunch_start=%s\n' "$2" "$(ps -o lstart= -p "$2" | awk '{$1=$1; print}')" >> "$1/.fm-live-lab" +} + lab_tmux() { # local dir dir=$(sed -n 's/^tmux_dir=//p' "$1/.fm-live-lab") @@ -311,7 +315,7 @@ printf '%s\n' "$STRAY_CHILD" >> "$TMP_ROOT/pids" # A launch-recorded process and its child must be stopped even when not in tmux. sleep 600 & OWNED=$! -printf 'launch_pid=%s\n' "$OWNED" >> "$C/.fm-live-lab" +record_pid "$C" "$OWNED" # A sibling lab root that shares this root as a string prefix is not this lab. mkdir -p "${C}2" printf 'sleep 600\n' > "${C}2/stray.sh" @@ -361,6 +365,45 @@ assert_equals '[1,["/elsewhere/project"]]' "$kept" "down rewrites the symlink's assert_equals "$HOME_STORE_BEFORE" "$(digest "$HOME/.claude.json")" "down leaves the default store alone" pass "check and down use the recorded Claude store and keep a symlinked store linked" +# TERM handlers may write trust again, and an uncooperative lab process must +# be killed before the store or lab directory is removed. +X=$(make_lab x claude) +cat > "$TMP_ROOT/exit-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM +while :; do sleep 0.1; done +SH +bash "$TMP_ROOT/exit-rewriter.sh" "$HOME/.claude.json" "$X/home" "$TMP_ROOT/rewrote" & +REWRITER=$! +bash -c 'trap "" TERM; while :; do sleep 0.1; done' & +STUBBORN=$! +sleep 0.2 +printf '%s\n%s\n' "$REWRITER" "$STUBBORN" >> "$TMP_ROOT/pids" +record_pid "$X" "$REWRITER" +record_pid "$X" "$STUBBORN" +out=$("$LIVE_LAB" down "$X" 2>&1) +expect_code 0 "$?" "down waits for lab processes: $out" +wait "$REWRITER" 2>/dev/null || true +assert_equals rewrote "$(cat "$TMP_ROOT/rewrote" 2>/dev/null)" "TERM handler rewrote its Claude key before down returned" +! kill -0 "$STUBBORN" 2>/dev/null || fail "down must kill a TERM-resistant lab process" +sleep 1.5 +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$X") +assert_equals "" "$lab_keys" "no exiting lab process re-adds Claude trust" +pass "down waits for TERM handlers and escalates before removing trust" + +# A reused PID with a different start time must not own its new process tree. +Y=$(make_lab y claude) +bash -c 'sleep 600 & echo $! > "$1"; wait' _ "$TMP_ROOT/reused-child" & REUSED=$! +until [ -s "$TMP_ROOT/reused-child" ]; do sleep 0.1; done +REUSED_CHILD=$(cat "$TMP_ROOT/reused-child") +printf '%s\n%s\n' "$REUSED" "$REUSED_CHILD" >> "$TMP_ROOT/pids" +printf 'launch_pid=%s\nlaunch_start=Mon Jan 1 00:00:00 1990\n' "$REUSED" >> "$Y/.fm-live-lab" +out=$("$LIVE_LAB" down "$Y" 2>&1) +expect_code 0 "$?" "down skips the mismatched root: $out" +kill -0 "$REUSED" 2>/dev/null || fail "down killed a reused PID" +kill -0 "$REUSED_CHILD" 2>/dev/null || fail "down killed the reused PID's child" +pass "down ignores roots with mismatched start times" + printf '{"trusted":["/somewhere"]}\n' > "$HOME/.pi/agent/trust.json" run_check "$P" assert_contains "$CHECK_OUT" "fail trust: the Pi trust store changed since up began" "a written Pi trust store is caught" @@ -403,6 +446,52 @@ expect_code 0 "$?" "down cleans a lab whose checkout failed: $out" assert_absent "$PARTIAL" "partial lab removed" pass "up gives both task IDs a long nonce and down cleans partial setup" +# A rival Claude writer drops the newly registered primary key once. The +# stand-in primary checks the store on startup, while the readiness probe fails. +UPSRC="$TMP_ROOT/up-source" +mkdir -p "$UPSRC" +cp -R "$ROOT/bin" "$UPSRC/bin" +cp "$ROOT/AGENTS.md" "$UPSRC/AGENTS.md" +git -C "$UPSRC" init -q -b main +git -C "$UPSRC" add -A +git -C "$UPSRC" -c user.name=t -c user.email=t@example.invalid commit -qm source +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" +cat > "$FAKEBIN/claude" <<'SH' +#!/usr/bin/env bash +sleep 1.5 +if node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' "$HOME/.claude.json" "$FM_HOME"; then + echo present > "$FM_HOME/../claude-launch-trust" +else + echo absent > "$FM_HOME/../claude-launch-trust" +fi +: > "$FM_HOME/state/.session-start-complete" +exec sleep 600 +SH +chmod +x "$FAKEBIN/claude" +U="$TMP_ROOT/up-lab" +( + end=$(( $(date +%s) + 120 )) + until node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects?.[process.argv[2]]?1:0)' "$HOME/.claude.json" "$U/home" 2>/dev/null; do + [ "$(date +%s)" -lt "$end" ] || exit 1 + sleep 0.05 + done + sleep 0.5 + node -e 'const fs=require("node:fs");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,"utf8"));delete j.projects[k];fs.writeFileSync(s,JSON.stringify(j))' "$HOME/.claude.json" "$U/home" + echo dropped > "$TMP_ROOT/rival-dropped" +) & +RIVAL=$! +printf '%s\n' "$RIVAL" >> "$TMP_ROOT/pids" +out=$(PATH="$FAKEBIN:$PATH" "$LIVE_LAB" up --harness claude --source "$UPSRC" --ref HEAD --timeout 1 "$U" 2>&1) +expect_code 1 "$?" "stand-in primary does not answer probe" +wait "$RIVAL" 2>/dev/null || true +assert_equals dropped "$(cat "$TMP_ROOT/rival-dropped" 2>/dev/null)" "rival dropped primary trust once" +assert_equals present "$(cat "$U/claude-launch-trust" 2>/dev/null)" "primary launched trusted after the rival write" +assert_contains "$out" "ok trust: $U/home is trusted in the Claude store" "readiness sees primary trust" +out=$("$LIVE_LAB" down "$U" 2>&1) +expect_code 0 "$?" "down removes the up-built lab: $out" +pass "up re-registers primary trust after a concurrent Claude write" + # ---- fm-claude-trust.sh --lab-home ------------------------------------------- T="$TMP_ROOT/trust" From eeb406a364dec9475b1f328af52427c8feff3be4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 15:54:43 -0700 Subject: [PATCH 13/19] no-mistakes(ci): Fixed the pre-primary settle wait, worker gate instructions, unused retry variable, and teardown PID revalidation in bin/fm-live-lab.sh. Added behavioral tests in tests/fm-live-lab.test.sh. Both requested commands pass: tests/fm-live-lab.test.sh and bin/fm-lint.sh --- bin/fm-live-lab.sh | 20 +++++------ tests/fm-live-lab.test.sh | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 183170a556f..8982d9694a2 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -61,7 +61,7 @@ # (local-only +yolo), a scaffolded brief, a backlog item, and # a real bin/fm-spawn.sh worker that parks on # /home/data//gate until that file -# exists; touch it to let the worker finish. +# exists; touch the gate and message the worker to resume. # primary window main: claude --setting-sources project,local # (default sonnet, medium, permission mode auto) or pi # (default openai-codex/gpt-6-luna, medium), launched @@ -418,8 +418,8 @@ spawn_worker() { local brief="$LAB/data/$WORKER_ID/brief.md" gate="$GATE" make_notes_project || return 1 (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-brief.sh" "$WORKER_ID" notes --mode local-only) >/dev/null || return 1 - TASK_TEXT="Lab gated worker for a live supervision lab. Add count_notes(), which returns how many notes are stored, to notes/__init__.py with a unit test, but only after the gate file $gate exists." \ - SPEC_TEXT="Right after setup, append one paused status line that names the gate file $gate, then check for that file every 15 seconds and do nothing else until it exists. Then implement count_notes() in notes/__init__.py and a test in tests/test_notes.py, run python3 -m unittest discover -s tests, commit, and report done. Nothing else is in scope." \ + TASK_TEXT="Lab gated worker for a live supervision lab. Add count_notes(), which returns how many notes are stored, to notes/__init__.py with a unit test, but only after the gate file $gate exists and you receive a message to resume." \ + SPEC_TEXT="Right after setup, append one paused status line naming the gate file $gate and end your turn. Do not poll or sleep in a foreground command. When a later message resumes you, check that $gate exists before implementing count_notes() in notes/__init__.py and a test in tests/test_notes.py; if it is absent, remain paused and end your turn again. Once the gate exists, run python3 -m unittest discover -s tests, commit, and report done. Nothing else is in scope." \ python3 - "$brief" <<'PY' || return 1 import os, sys path = sys.argv[1] @@ -531,17 +531,17 @@ cmd_up() { if [ "$worker" = yes ]; then spawn_worker || die "cannot launch the gated worker" record_launch_pid "$(window_field worker '#{pane_pid}')" - echo "gate: $GATE (touch to release the worker)" + echo "gate: $GATE (touch, then message the worker to resume)" fi local -a primary=() if [ "$harness" = claude ]; then - local settle=$(( $(date +%s) + 300 )) attempt - until { [ "$mate" != yes ] || check_mate >/dev/null; } && { [ "$worker" != yes ] || check_worker >/dev/null; }; do - [ "$(date +%s)" -lt "$settle" ] || die "mate or worker did not become ready before Claude primary trust registration" + local settle=$(( $(date +%s) + 300 )) retry + until { [ "$mate" != yes ] || check_mate >/dev/null; } && { [ "$worker" != yes ] || [ -s "$LAB/state/$WORKER_ID.status" ]; }; do + [ "$(date +%s)" -lt "$settle" ] || break sleep 2 done - for attempt in 1 2 3; do + for (( retry=0; retry<3; retry++ )); do lab_run "$CLAUDE_TRUST" --lab-home "$LAB" >/dev/null || die "cannot register Claude trust for the lab home" sleep 1 lab_trust_present && break @@ -680,7 +680,7 @@ cmd_down() { kill $pids 2>/dev/null || true fi for n in {1..40}; do - survivors=$(live_pids "$pids $(lab_pids)") + survivors=$(live_pids "$(lab_pids)") [ -n "$survivors" ] || break if [ "$n" -eq 20 ]; then # shellcheck disable=SC2086 # One pid per word. @@ -688,7 +688,7 @@ cmd_down() { fi sleep 0.5 done - survivors=$(live_pids "$pids $(lab_pids)") + survivors=$(live_pids "$(lab_pids)") [ -z "$survivors" ] || die "refusing to remove the lab: its processes did not exit: $(echo "$survivors" | tr '\n' ' ')" echo "stopped: lab tmux server and lab processes" # A spawn keeps /tmp/fm- and /tmp/fm-+. diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index ce1257cfda8..9036f6998e5 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -404,6 +404,38 @@ kill -0 "$REUSED" 2>/dev/null || fail "down killed a reused PID" kill -0 "$REUSED_CHILD" 2>/dev/null || fail "down killed the reused PID's child" pass "down ignores roots with mismatched start times" +# Simulate a recorded PID changing identity after TERM: the first process +# snapshot matches its start time, subsequent snapshots describe a reused PID. +Z=$(make_lab z claude) +python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' & +REPLACED=$! +printf '%s\n' "$REPLACED" >> "$TMP_ROOT/pids" +record_pid "$Z" "$REPLACED" +mkdir -p "$TMP_ROOT/ps-bin" +printf '%s\n' "$REPLACED" > "$TMP_ROOT/replaced-pid" +cat > "$TMP_ROOT/ps-bin/ps" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -axo ]; then + count=$(cat "$PS_COUNT" 2>/dev/null || echo 0) + echo "$((count + 1))" > "$PS_COUNT" + if [ "$count" -gt 0 ]; then + "$REAL_PS" "$@" | awk -v target="$(cat "$PS_TARGET")" '$1 == target {$3="Mon"; $4="Jan"; $5="1"; $6="00:00:00"; $7="1990"} {print}' + else + "$REAL_PS" "$@" + fi +else + "$REAL_PS" "$@" +fi +SH +chmod +x "$TMP_ROOT/ps-bin/ps" +start=$(date +%s) +out=$(REAL_PS="$(command -v ps)" PS_COUNT="$TMP_ROOT/ps-count" PS_TARGET="$TMP_ROOT/replaced-pid" PATH="$TMP_ROOT/ps-bin:$PATH" "$LIVE_LAB" down "$Z" 2>&1) +expect_code 0 "$?" "down must not treat the changed PID as a survivor: $out" +[ "$(( $(date +%s) - start ))" -lt 8 ] || fail "down waited on a PID with a different start time" +kill -0 "$REPLACED" 2>/dev/null || fail "down killed a PID after its recorded identity changed" +kill "$REPLACED" 2>/dev/null || true +pass "down revalidates process identity during its bounded wait" + printf '{"trusted":["/somewhere"]}\n' > "$HOME/.pi/agent/trust.json" run_check "$P" assert_contains "$CHECK_OUT" "fail trust: the Pi trust store changed since up began" "a written Pi trust store is caught" @@ -455,6 +487,48 @@ cp "$ROOT/AGENTS.md" "$UPSRC/AGENTS.md" git -C "$UPSRC" init -q -b main git -C "$UPSRC" add -A git -C "$UPSRC" -c user.name=t -c user.email=t@example.invalid commit -qm source +# A worker can have written its first status while still working. That must +# not hold up the Claude primary; the generated brief must ask it to end its +# turn on the gate instead of running a foreground polling command. +WORKSRC="$TMP_ROOT/worker-source" +cp -R "$UPSRC" "$WORKSRC" +cat > "$WORKSRC/bin/fm-brief.sh" <<'SH' +#!/usr/bin/env bash +mkdir -p "$FM_HOME/data/$1" +printf '{TASK}\n{FIRSTMATE_SPEC}\n' > "$FM_HOME/data/$1/brief.md" +SH +cat > "$WORKSRC/bin/fm-tasks-axi.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +cat > "$WORKSRC/bin/fm-spawn.sh" <<'SH' +#!/usr/bin/env bash +id=$1 +tmux new-window -d -t firstmate: -n "fm-$id" -c "$FM_HOME" 'exec sleep 600' +printf 'window=firstmate:fm-%s\n' "$id" > "$FM_HOME/state/$id.meta" +printf 'working [at=1]: setting up\n' > "$FM_HOME/state/$id.status" +SH +chmod +x "$WORKSRC/bin/"{fm-brief,fm-tasks-axi,fm-spawn}.sh +git -C "$WORKSRC" add -A +git -C "$WORKSRC" -c user.name=t -c user.email=t@example.invalid commit -qm stubs +W="$TMP_ROOT/worker-up" +out=$(PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$W" 2>&1) +expect_code 1 "$?" "unanswered probe leaves worker lab for inspection: $out" +assert_contains "$out" "primary: claude" "the unparked worker did not block primary launch" +assert_contains "$out" "gate: $W/home/data/" "up shows the gate path" +assert_contains "$out" "then message the worker to resume" "up explains the release message" +worker_id=$(sed -n 's/^worker_id=//p' "$W/.fm-live-lab") +brief=$(<"$W/home/data/$worker_id/brief.md") +assert_contains "$brief" "append one paused status line naming the gate file" "worker declares its wait" +assert_contains "$brief" "and end your turn" "worker ends its waiting turn" +assert_contains "$brief" "Do not poll or sleep in a foreground command" "worker does not run a blocking wait" +assert_contains "$brief" "When a later message resumes you, check that" "worker checks the gate after a message" +assert_contains "$out" "fail worker: the worker is not currently parked" "final readiness remains strict" +node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects?.[process.argv[2]]?.hasTrustDialogAccepted===true?0:1)' "$HOME/.claude.json" "$W/home" || fail "primary trust was not registered before launch" +out=$("$LIVE_LAB" down "$W" 2>&1) +expect_code 0 "$?" "down cleans the worker lab: $out" +pass "up launches primary after worker status without weakening final parked readiness" + FAKEBIN="$TMP_ROOT/fakebin" mkdir -p "$FAKEBIN" cat > "$FAKEBIN/claude" <<'SH' From 1381c1cd6e2c0ae015c2fd3b093f96a055b6c851 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 16:59:15 -0700 Subject: [PATCH 14/19] no-mistakes(ci): Fixed teardown to track pre-kill lab processes by PID and start time, including children orphaned when a root exits. Up now rejects an empty pane PID before calling ps. Added regression tests and a Linux-safe worker fixture. bin/fm-lint.sh and tests/fm-live-lab.test.sh pass --- bin/fm-live-lab.sh | 35 +++++++++++++++++-------- tests/fm-live-lab.test.sh | 54 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 8982d9694a2..dee0ae53142 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -580,6 +580,7 @@ cmd_up() { record_launch_pid() { local start + case "${1:-}" in ''|*[!0-9]*) die "cannot record lab process: missing or invalid PID '${1:-}'" ;; esac start=$(ps -o lstart= -p "$1" | awk '{$1=$1; print}') [ -n "$start" ] || die "cannot record start time for lab process $1" printf 'launch_pid=%s\nlaunch_start=%s\n' "$1" "$start" >> "$ROOT/$RECORD_NAME" @@ -613,12 +614,18 @@ lab_pids() { }' } +# pairs captured before tmux shutdown. Recheck identity even +# after a root exits and its children are reparented or a PID is reused. live_pids() { - local pid state - for pid in $1; do - state=$(ps -o stat= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') - case "$state" in ''|Z*) ;; *) echo "$pid" ;; esac - done + local pid start current + while IFS=$'\t' read -r pid start; do + [ -n "$pid" ] || continue + current=$(ps -o stat=,lstart= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') + case "$current" in ''|Z*) ;; *) + [ "${current#* }" = "$start" ] && echo "$pid" + ;; + esac + done <<< "$1" } forget_claude_entries() { # remove every project entry at or under ROOT; prints the count @@ -671,16 +678,22 @@ NODE cmd_down() { load_lab "${1:-}" - local rc=0 pids survivors n removed added id meta dir home_hash + local rc=0 pids pairs pid start survivors n removed added id meta dir home_hash local -a ids=() pids=$(lab_pids) + pairs= + for pid in $pids; do + start=$(ps -o lstart= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') + [ -n "$start" ] && pairs+="$pid"$'\t'"$start"$'\n' + done lab_tmux kill-server 2>/dev/null || true - if [ -n "$pids" ]; then - # shellcheck disable=SC2086 # One pid per word, captured before tmux reparents panes. - kill $pids 2>/dev/null || true + survivors=$(live_pids "$pairs") + if [ -n "$survivors" ]; then + # shellcheck disable=SC2086 # One identity-checked pid per word. + kill $survivors 2>/dev/null || true fi for n in {1..40}; do - survivors=$(live_pids "$(lab_pids)") + survivors=$(live_pids "$pairs") [ -n "$survivors" ] || break if [ "$n" -eq 20 ]; then # shellcheck disable=SC2086 # One pid per word. @@ -688,7 +701,7 @@ cmd_down() { fi sleep 0.5 done - survivors=$(live_pids "$(lab_pids)") + survivors=$(live_pids "$pairs") [ -z "$survivors" ] || die "refusing to remove the lab: its processes did not exit: $(echo "$survivors" | tr '\n' ' ')" echo "stopped: lab tmux server and lab processes" # A spawn keeps /tmp/fm- and /tmp/fm-+. diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 9036f6998e5..dadbc030bf1 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -391,6 +391,28 @@ lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.a assert_equals "" "$lab_keys" "no exiting lab process re-adds Claude trust" pass "down waits for TERM handlers and escalates before removing trust" +# A pane root may exit on TERM while its child remains alive, reparented and +# still able to write Claude trust. Teardown must wait for the captured child. +ORPHAN=$(make_lab orphan claude) +cat > "$TMP_ROOT/orphan-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM +while :; do sleep 0.1; done +SH +bash -c 'bash "$1" "$2" "$3" "$4" & echo $! > "$5"; while :; do sleep 0.1; done' _ \ + "$TMP_ROOT/orphan-rewriter.sh" "$HOME/.claude.json" "$ORPHAN/home" "$TMP_ROOT/orphan-rewrote" "$TMP_ROOT/orphan-child" & +ORPHAN_ROOT=$! +until [ -s "$TMP_ROOT/orphan-child" ]; do sleep 0.1; done +ORPHAN_CHILD=$(cat "$TMP_ROOT/orphan-child") +printf '%s\n%s\n' "$ORPHAN_ROOT" "$ORPHAN_CHILD" >> "$TMP_ROOT/pids" +record_pid "$ORPHAN" "$ORPHAN_ROOT" +out=$("$LIVE_LAB" down "$ORPHAN" 2>&1) +expect_code 0 "$?" "down waits for a reparented child: $out" +assert_equals rewrote "$(cat "$TMP_ROOT/orphan-rewrote" 2>/dev/null)" "child finished its TERM handler before cleanup" +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$ORPHAN") +assert_equals "" "$lab_keys" "reparented child cannot re-add trust after down" +pass "down waits for captured descendants after their root exits" + # A reused PID with a different start time must not own its new process tree. Y=$(make_lab y claude) bash -c 'sleep 600 & echo $! > "$1"; wait' _ "$TMP_ROOT/reused-child" & REUSED=$! @@ -415,11 +437,11 @@ mkdir -p "$TMP_ROOT/ps-bin" printf '%s\n' "$REPLACED" > "$TMP_ROOT/replaced-pid" cat > "$TMP_ROOT/ps-bin/ps" <<'SH' #!/usr/bin/env bash -if [ "${1:-}" = -axo ]; then +if [ "${1:-}" = -o ] && [ "${2:-}" = 'stat=,lstart=' ] && [ "${4:-}" = "$(cat "$PS_TARGET")" ]; then count=$(cat "$PS_COUNT" 2>/dev/null || echo 0) echo "$((count + 1))" > "$PS_COUNT" if [ "$count" -gt 0 ]; then - "$REAL_PS" "$@" | awk -v target="$(cat "$PS_TARGET")" '$1 == target {$3="Mon"; $4="Jan"; $5="1"; $6="00:00:00"; $7="1990"} {print}' + echo 'S Mon Jan 1 00:00:00 1990' else "$REAL_PS" "$@" fi @@ -504,7 +526,14 @@ SH cat > "$WORKSRC/bin/fm-spawn.sh" <<'SH' #!/usr/bin/env bash id=$1 -tmux new-window -d -t firstmate: -n "fm-$id" -c "$FM_HOME" 'exec sleep 600' +tmux new-window -d -t firstmate: -n "fm-$id" -c "$FM_HOME" 'exec sleep 600' || exit 1 +# Wait for tmux to publish the real pane PID before the builder records it. +for (( n=0; n<30; n++ )); do + pid=$(tmux display-message -p -t "firstmate:=fm-$id" '#{pane_pid}') + [ -z "$pid" ] || break + sleep 0.1 +done +[ -n "$pid" ] || exit 1 printf 'window=firstmate:fm-%s\n' "$id" > "$FM_HOME/state/$id.meta" printf 'working [at=1]: setting up\n' > "$FM_HOME/state/$id.status" SH @@ -529,6 +558,25 @@ out=$("$LIVE_LAB" down "$W" 2>&1) expect_code 0 "$?" "down cleans the worker lab: $out" pass "up launches primary after worker status without weakening final parked readiness" +# If a spawn reports success without a pane, fail at the missing PID rather +# than invoking ps with an empty -p argument or waiting for readiness. +cat > "$WORKSRC/bin/fm-spawn.sh" <<'SH' +#!/usr/bin/env bash +id=$1 +printf 'window=firstmate:fm-%s\n' "$id" > "$FM_HOME/state/$id.meta" +printf 'working [at=1]: setting up\n' > "$FM_HOME/state/$id.status" +SH +git -C "$WORKSRC" add bin/fm-spawn.sh +git -C "$WORKSRC" -c user.name=t -c user.email=t@example.invalid commit -qm missing-pane +NO_PANE="$TMP_ROOT/no-pane" +out=$(PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$NO_PANE" 2>&1) +expect_code 1 "$?" "up refuses a worker without a pane PID: $out" +assert_contains "$out" "cannot record lab process: missing or invalid PID ''" "missing pane PID fails at launch recording" +assert_not_contains "$out" "list of process IDs must follow -p" "ps never receives an empty PID" +out=$("$LIVE_LAB" down "$NO_PANE" 2>&1) +expect_code 0 "$?" "down cleans the missing-pane lab: $out" +pass "up fails immediately when a spawned worker has no pane PID" + FAKEBIN="$TMP_ROOT/fakebin" mkdir -p "$FAKEBIN" cat > "$FAKEBIN/claude" <<'SH' From 637779af408e7ac8780094f413441e99d4ff7cec Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 18:37:45 -0700 Subject: [PATCH 15/19] no-mistakes(ci): Fixed teardown tracking for children spawned during shutdown and made the worker fixture verify its exact window with a Linux-available shell. Both requested checks pass. The lab test takes about 66 seconds locally, so the under-one-minute target remains unmet --- bin/fm-live-lab.sh | 24 +++++++++++++++ tests/fm-live-lab.test.sh | 63 +++++++++++++++++++++++++++++---------- 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index dee0ae53142..a165eeb504a 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -614,6 +614,27 @@ lab_pids() { }' } +# Extend the pre-kill snapshot with descendants of still-matching processes. +# Retain old pairs so reparented children remain tracked after their root exits. +expand_pairs() { + awk ' + NR==FNR { split($0, fields, "\t"); if (fields[1] ~ /^[0-9]+$/) saved[fields[1]]=fields[2]; next } + { + pid=$1; parent[pid]=$2; state[pid]=$3 + start[pid]=$4 " " $5 " " $6 " " $7 " " $8 + if (pid in saved && start[pid] == saved[pid] && state[pid] !~ /^Z/) owned[pid]=1 + } + END { + for (pid in saved) print pid "\t" saved[pid] + for (pid in parent) { + if (pid in saved || state[pid] ~ /^Z/) continue + for (p=parent[pid]; p > 1 && (p in parent); p=parent[p]) { + if (p in owned) { print pid "\t" start[pid]; break } + } + } + }' <(printf '%s\n' "$1") <(ps -axo pid=,ppid=,stat=,lstart=) +} + # pairs captured before tmux shutdown. Recheck identity even # after a root exits and its children are reparented or a PID is reused. live_pids() { @@ -687,12 +708,14 @@ cmd_down() { [ -n "$start" ] && pairs+="$pid"$'\t'"$start"$'\n' done lab_tmux kill-server 2>/dev/null || true + pairs=$(expand_pairs "$pairs") survivors=$(live_pids "$pairs") if [ -n "$survivors" ]; then # shellcheck disable=SC2086 # One identity-checked pid per word. kill $survivors 2>/dev/null || true fi for n in {1..40}; do + pairs=$(expand_pairs "$pairs") survivors=$(live_pids "$pairs") [ -n "$survivors" ] || break if [ "$n" -eq 20 ]; then @@ -701,6 +724,7 @@ cmd_down() { fi sleep 0.5 done + pairs=$(expand_pairs "$pairs") survivors=$(live_pids "$pairs") [ -z "$survivors" ] || die "refusing to remove the lab: its processes did not exit: $(echo "$survivors" | tr '\n' ' ')" echo "stopped: lab tmux server and lab processes" diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index dadbc030bf1..213e66c82b8 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -18,8 +18,11 @@ LIVE_LAB="$ROOT/bin/fm-live-lab.sh" TRUST="$ROOT/bin/fm-claude-trust.sh" live_lab_cleanup() { - local dir pid - while read -r pid; do [ -n "$pid" ] && kill "$pid" 2>/dev/null; done < "$TMP_ROOT/pids" + local dir pid marker + for marker in "$TMP_ROOT/orphan-child" "$TMP_ROOT/late-child" "$TMP_ROOT/reused-child"; do + [ ! -s "$marker" ] || printf '%s\n' "$(<"$marker")" >> "$TMP_ROOT/pids" + done + while read -r pid; do [ -n "$pid" ] && { pkill -P "$pid" 2>/dev/null || true; kill "$pid" 2>/dev/null || true; }; done < "$TMP_ROOT/pids" while read -r dir; do [ -n "$dir" ] || continue env -u TMUX TMUX_TMPDIR="$dir" tmux kill-server 2>/dev/null @@ -307,14 +310,15 @@ fm_write_meta "$CH/state/$OTHER_ID.meta" "window=firstmate:fm-$OTHER_ID" "tasktm mkdir -p "/tmp/fm-$WORKER_ID/gotmp" "/tmp/fm-$MATE_ID" "/tmp/fm-$WORKER_ID+$C_HASH" "/tmp/fm-$OTHER_ID+$C_HASH" "/tmp/fm-$OTHER_ID" # An outsider opening a lab path is not owned by the lab. printf 'sleep 600\n' > "$C/stray.sh" -bash "$C/stray.sh" & +bash "$C/stray.sh" >/dev/null 2>&1 & STRAY=$! printf '%s\n' "$STRAY" >> "$TMP_ROOT/pids" until STRAY_CHILD=$(pgrep -P "$STRAY" sleep); do sleep 0.1; done printf '%s\n' "$STRAY_CHILD" >> "$TMP_ROOT/pids" # A launch-recorded process and its child must be stopped even when not in tmux. -sleep 600 & +sleep 600 >/dev/null 2>&1 & OWNED=$! +printf '%s\n' "$OWNED" >> "$TMP_ROOT/pids" record_pid "$C" "$OWNED" # A sibling lab root that shares this root as a string prefix is not this lab. mkdir -p "${C}2" @@ -373,9 +377,9 @@ store=$1 key=$2 marker=$3 trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM while :; do sleep 0.1; done SH -bash "$TMP_ROOT/exit-rewriter.sh" "$HOME/.claude.json" "$X/home" "$TMP_ROOT/rewrote" & +bash "$TMP_ROOT/exit-rewriter.sh" "$HOME/.claude.json" "$X/home" "$TMP_ROOT/rewrote" >/dev/null 2>&1 & REWRITER=$! -bash -c 'trap "" TERM; while :; do sleep 0.1; done' & +bash -c 'trap "" TERM; while :; do sleep 0.1; done' >/dev/null 2>&1 & STUBBORN=$! sleep 0.2 printf '%s\n%s\n' "$REWRITER" "$STUBBORN" >> "$TMP_ROOT/pids" @@ -413,6 +417,27 @@ lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.a assert_equals "" "$lab_keys" "reparented child cannot re-add trust after down" pass "down waits for captured descendants after their root exits" +# A recorded process can create a new descendant only after TERM arrives. +LATE=$(make_lab late claude) +cat > "$TMP_ROOT/late-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'bash -c '\''sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$1" "$2"'\'' _ "$store" "$key" >/dev/null 2>&1 & echo $! > "$marker"; sleep 0.4; exit 0' TERM +while :; do sleep 0.1; done +SH +bash "$TMP_ROOT/late-rewriter.sh" "$HOME/.claude.json" "$LATE/home" "$TMP_ROOT/late-child" >/dev/null 2>&1 & +LATE_ROOT=$! +printf '%s\n' "$LATE_ROOT" >> "$TMP_ROOT/pids" +record_pid "$LATE" "$LATE_ROOT" +out=$("$LIVE_LAB" down "$LATE" 2>&1) +expect_code 0 "$?" "down waits for a child born during TERM: $out" +assert_present "$TMP_ROOT/late-child" "TERM handler spawned a child" +LATE_CHILD=$(cat "$TMP_ROOT/late-child") +printf '%s\n' "$LATE_CHILD" >> "$TMP_ROOT/pids" +case "$(ps -o stat= -p "$LATE_CHILD" 2>/dev/null | awk '{$1=$1; print}')" in ''|Z*) ;; *) fail "down leaves a TERM-spawned child alive" ;; esac +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$LATE") +assert_equals "" "$lab_keys" "TERM-spawned child cannot re-add trust after down" +pass "down tracks descendants spawned during TERM" + # A reused PID with a different start time must not own its new process tree. Y=$(make_lab y claude) bash -c 'sleep 600 & echo $! > "$1"; wait' _ "$TMP_ROOT/reused-child" & REUSED=$! @@ -487,7 +512,11 @@ pass "up refuses an existing root and an unsupported harness" # launching a harness; down can still clean this partial lab. PARTIAL="$TMP_ROOT/partial-lab" mkdir -p "$TMP_ROOT/stub-bin" -printf '#!/bin/sh\nexit 99\n' > "$TMP_ROOT/stub-bin/claude" +cat > "$TMP_ROOT/stub-bin/claude" <<'SH' +#!/bin/sh +: > "$FM_HOME/state/.session-start-complete" +exec sleep 45 >/dev/null 2>&1 +SH chmod +x "$TMP_ROOT/stub-bin/claude" out=$(PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --source "$TMP_ROOT/missing-origin" "$PARTIAL" 2>&1) expect_code 1 "$?" "an unavailable source stops up before launch" @@ -526,14 +555,16 @@ SH cat > "$WORKSRC/bin/fm-spawn.sh" <<'SH' #!/usr/bin/env bash id=$1 -tmux new-window -d -t firstmate: -n "fm-$id" -c "$FM_HOME" 'exec sleep 600' || exit 1 -# Wait for tmux to publish the real pane PID before the builder records it. +tmux new-window -d -t firstmate: -n "fm-$id" -c "$FM_HOME" 'exec sleep 45 >/dev/null 2>&1' || exit 1 +# A missing =name can silently resolve to the current window: verify the name. for (( n=0; n<30; n++ )); do - pid=$(tmux display-message -p -t "firstmate:=fm-$id" '#{pane_pid}') - [ -z "$pid" ] || break + if tmux list-windows -t firstmate -F '#{window_name}' | grep -Fxq "fm-$id"; then + pid=$(tmux display-message -p -t "firstmate:=fm-$id" '#{pane_pid}') + [ -z "$pid" ] || break + fi sleep 0.1 done -[ -n "$pid" ] || exit 1 +[ -n "${pid:-}" ] || exit 1 printf 'window=firstmate:fm-%s\n' "$id" > "$FM_HOME/state/$id.meta" printf 'working [at=1]: setting up\n' > "$FM_HOME/state/$id.status" SH @@ -541,7 +572,7 @@ chmod +x "$WORKSRC/bin/"{fm-brief,fm-tasks-axi,fm-spawn}.sh git -C "$WORKSRC" add -A git -C "$WORKSRC" -c user.name=t -c user.email=t@example.invalid commit -qm stubs W="$TMP_ROOT/worker-up" -out=$(PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$W" 2>&1) +out=$(SHELL=/bin/sh PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$W" 2>&1) expect_code 1 "$?" "unanswered probe leaves worker lab for inspection: $out" assert_contains "$out" "primary: claude" "the unparked worker did not block primary launch" assert_contains "$out" "gate: $W/home/data/" "up shows the gate path" @@ -569,7 +600,7 @@ SH git -C "$WORKSRC" add bin/fm-spawn.sh git -C "$WORKSRC" -c user.name=t -c user.email=t@example.invalid commit -qm missing-pane NO_PANE="$TMP_ROOT/no-pane" -out=$(PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$NO_PANE" 2>&1) +out=$(SHELL=/bin/sh PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$NO_PANE" 2>&1) expect_code 1 "$?" "up refuses a worker without a pane PID: $out" assert_contains "$out" "cannot record lab process: missing or invalid PID ''" "missing pane PID fails at launch recording" assert_not_contains "$out" "list of process IDs must follow -p" "ps never receives an empty PID" @@ -588,7 +619,7 @@ else echo absent > "$FM_HOME/../claude-launch-trust" fi : > "$FM_HOME/state/.session-start-complete" -exec sleep 600 +exec sleep 45 >/dev/null 2>&1 SH chmod +x "$FAKEBIN/claude" U="$TMP_ROOT/up-lab" @@ -604,7 +635,7 @@ U="$TMP_ROOT/up-lab" ) & RIVAL=$! printf '%s\n' "$RIVAL" >> "$TMP_ROOT/pids" -out=$(PATH="$FAKEBIN:$PATH" "$LIVE_LAB" up --harness claude --source "$UPSRC" --ref HEAD --timeout 1 "$U" 2>&1) +out=$(SHELL=/bin/sh PATH="$FAKEBIN:$PATH" "$LIVE_LAB" up --harness claude --source "$UPSRC" --ref HEAD --timeout 1 "$U" 2>&1) expect_code 1 "$?" "stand-in primary does not answer probe" wait "$RIVAL" 2>/dev/null || true assert_equals dropped "$(cat "$TMP_ROOT/rival-dropped" 2>/dev/null)" "rival dropped primary trust once" From c7a65940c2ac99427db21687a466d58d3d212f89 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 19:58:14 -0700 Subject: [PATCH 16/19] =?UTF-8?q?no-mistakes(ci):=20Fixed=20ci-2=20and=20c?= =?UTF-8?q?i-4=20in=20bin/fm-live-lab.sh=20and=20tests/fm-live-lab.test.sh?= =?UTF-8?q?.=20Teardown=20now=20tracks=20identity-checked=20members=20of?= =?UTF-8?q?=20captured=20lab=20process=20groups,=20including=20children=20?= =?UTF-8?q?orphaned=20during=20shutdown,=20without=20signaling=20the=20cal?= =?UTF-8?q?ler=E2=80=99s=20group=20or=20unrelated=20processes.=20Lint=20pa?= =?UTF-8?q?ssed,=20and=20the=20lab=20test=20passed=20four=20times?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/fm-live-lab.sh | 32 ++++++++++++++++++++------------ tests/fm-live-lab.test.sh | 29 ++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index a165eeb504a..0041e2a0b66 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -614,25 +614,27 @@ lab_pids() { }' } -# Extend the pre-kill snapshot with descendants of still-matching processes. -# Retain old pairs so reparented children remain tracked after their root exits. +# Extend the pre-kill snapshot with descendants of still-matching processes +# and live members of captured lab process groups. Retain old pairs after reparenting. expand_pairs() { - awk ' + awk -v groups="$2" ' + BEGIN { split(groups, ids, /[[:space:]]+/); for (i in ids) if (ids[i] > 1) group[ids[i]]=1 } NR==FNR { split($0, fields, "\t"); if (fields[1] ~ /^[0-9]+$/) saved[fields[1]]=fields[2]; next } { - pid=$1; parent[pid]=$2; state[pid]=$3 - start[pid]=$4 " " $5 " " $6 " " $7 " " $8 + pid=$1; parent[pid]=$2; pgid[pid]=$3; state[pid]=$4 + start[pid]=$5 " " $6 " " $7 " " $8 " " $9 if (pid in saved && start[pid] == saved[pid] && state[pid] !~ /^Z/) owned[pid]=1 } END { for (pid in saved) print pid "\t" saved[pid] for (pid in parent) { if (pid in saved || state[pid] ~ /^Z/) continue + if (pgid[pid] in group) { print pid "\t" start[pid]; continue } for (p=parent[pid]; p > 1 && (p in parent); p=parent[p]) { if (p in owned) { print pid "\t" start[pid]; break } } } - }' <(printf '%s\n' "$1") <(ps -axo pid=,ppid=,stat=,lstart=) + }' <(printf '%s\n' "$1") <(ps -axo pid=,ppid=,pgid=,stat=,lstart=) } # pairs captured before tmux shutdown. Recheck identity even @@ -699,23 +701,29 @@ NODE cmd_down() { load_lab "${1:-}" - local rc=0 pids pairs pid start survivors n removed added id meta dir home_hash + local rc=0 pids pairs pid start survivors n removed added id meta dir home_hash groups pgid own_group caller_group local -a ids=() pids=$(lab_pids) - pairs= + pairs='' groups='' + own_group=$(ps -o pgid= -p "$$" | awk '{$1=$1; print}') + caller_group=$(ps -o pgid= -p "$PPID" | awk '{$1=$1; print}') for pid in $pids; do start=$(ps -o lstart= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') - [ -n "$start" ] && pairs+="$pid"$'\t'"$start"$'\n' + [ -n "$start" ] || continue + pairs+="$pid"$'\t'"$start"$'\n' + pgid=$(ps -o pgid=,lstart= -p "$pid" 2>/dev/null | awk -v start="$start" '{ if ($2 " " $3 " " $4 " " $5 " " $6 == start) print $1 }') + case "$pgid" in ''|0|1|*[!0-9]*) continue ;; esac + [ "$pgid" = "$own_group" ] || [ "$pgid" = "$caller_group" ] || groups+="$pgid " done lab_tmux kill-server 2>/dev/null || true - pairs=$(expand_pairs "$pairs") + pairs=$(expand_pairs "$pairs" "$groups") survivors=$(live_pids "$pairs") if [ -n "$survivors" ]; then # shellcheck disable=SC2086 # One identity-checked pid per word. kill $survivors 2>/dev/null || true fi for n in {1..40}; do - pairs=$(expand_pairs "$pairs") + pairs=$(expand_pairs "$pairs" "$groups") survivors=$(live_pids "$pairs") [ -n "$survivors" ] || break if [ "$n" -eq 20 ]; then @@ -724,7 +732,7 @@ cmd_down() { fi sleep 0.5 done - pairs=$(expand_pairs "$pairs") + pairs=$(expand_pairs "$pairs" "$groups") survivors=$(live_pids "$pairs") [ -z "$survivors" ] || die "refusing to remove the lab: its processes did not exit: $(echo "$survivors" | tr '\n' ' ')" echo "stopped: lab tmux server and lab processes" diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 213e66c82b8..6f5c8c16352 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -108,6 +108,9 @@ make_lab() { printf '%s\n' "$root" } +# Recorded fixture roots must not share the runner's process group. +start_group() { perl -e 'setpgrp(0,0); exec @ARGV' "$@" >/dev/null 2>&1 & } + record_pid() { # printf 'launch_pid=%s\nlaunch_start=%s\n' "$2" "$(ps -o lstart= -p "$2" | awk '{$1=$1; print}')" >> "$1/.fm-live-lab" } @@ -316,10 +319,15 @@ printf '%s\n' "$STRAY" >> "$TMP_ROOT/pids" until STRAY_CHILD=$(pgrep -P "$STRAY" sleep); do sleep 0.1; done printf '%s\n' "$STRAY_CHILD" >> "$TMP_ROOT/pids" # A launch-recorded process and its child must be stopped even when not in tmux. -sleep 600 >/dev/null 2>&1 & +start_group sleep 600 OWNED=$! printf '%s\n' "$OWNED" >> "$TMP_ROOT/pids" record_pid "$C" "$OWNED" +# A process in the runner's group is not part of any recorded lab group. +sleep 600 >/dev/null 2>&1 & +UNRELATED=$! +printf '%s\n' "$UNRELATED" >> "$TMP_ROOT/pids" +[ "$(ps -o pgid= -p "$UNRELATED" | awk '{$1=$1; print}')" != "$(ps -o pgid= -p "$OWNED" | awk '{$1=$1; print}')" ] || fail "fixture roots must have their own group" # A sibling lab root that shares this root as a string prefix is not this lab. mkdir -p "${C}2" printf 'sleep 600\n' > "${C}2/stray.sh" @@ -334,6 +342,7 @@ expect_code 0 "$?" "down of a clean Claude lab succeeds from a shell with anothe kill -0 "$STRAY" 2>/dev/null || fail "down leaves unrelated processes opening the lab path alone" kill -0 "$STRAY_CHILD" 2>/dev/null || fail "down leaves their descendants alone" ! kill -0 "$OWNED" 2>/dev/null || fail "down stops recorded launch processes" +kill -0 "$UNRELATED" 2>/dev/null || fail "down signalled an unrelated process outside recorded groups" assert_absent "$C" "down removes the lab root" kill -0 "$SIBLING" 2>/dev/null || fail "down leaves a sibling root's process running" pkill -P "$SIBLING" 2>/dev/null @@ -377,9 +386,9 @@ store=$1 key=$2 marker=$3 trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM while :; do sleep 0.1; done SH -bash "$TMP_ROOT/exit-rewriter.sh" "$HOME/.claude.json" "$X/home" "$TMP_ROOT/rewrote" >/dev/null 2>&1 & +start_group bash "$TMP_ROOT/exit-rewriter.sh" "$HOME/.claude.json" "$X/home" "$TMP_ROOT/rewrote" REWRITER=$! -bash -c 'trap "" TERM; while :; do sleep 0.1; done' >/dev/null 2>&1 & +start_group bash -c 'trap "" TERM; while :; do sleep 0.1; done' STUBBORN=$! sleep 0.2 printf '%s\n%s\n' "$REWRITER" "$STUBBORN" >> "$TMP_ROOT/pids" @@ -403,8 +412,9 @@ store=$1 key=$2 marker=$3 trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM while :; do sleep 0.1; done SH -bash -c 'bash "$1" "$2" "$3" "$4" & echo $! > "$5"; while :; do sleep 0.1; done' _ \ - "$TMP_ROOT/orphan-rewriter.sh" "$HOME/.claude.json" "$ORPHAN/home" "$TMP_ROOT/orphan-rewrote" "$TMP_ROOT/orphan-child" & +# shellcheck disable=SC2016 # Positional parameters expand in the launched shell. +start_group bash -c 'bash "$1" "$2" "$3" "$4" & echo $! > "$5"; while :; do sleep 0.1; done' _ \ + "$TMP_ROOT/orphan-rewriter.sh" "$HOME/.claude.json" "$ORPHAN/home" "$TMP_ROOT/orphan-rewrote" "$TMP_ROOT/orphan-child" ORPHAN_ROOT=$! until [ -s "$TMP_ROOT/orphan-child" ]; do sleep 0.1; done ORPHAN_CHILD=$(cat "$TMP_ROOT/orphan-child") @@ -421,10 +431,10 @@ pass "down waits for captured descendants after their root exits" LATE=$(make_lab late claude) cat > "$TMP_ROOT/late-rewriter.sh" <<'SH' store=$1 key=$2 marker=$3 -trap 'bash -c '\''sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$1" "$2"'\'' _ "$store" "$key" >/dev/null 2>&1 & echo $! > "$marker"; sleep 0.4; exit 0' TERM +trap 'bash -c '\''sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$1" "$2"'\'' _ "$store" "$key" >/dev/null 2>&1 & echo $! > "$marker"; exit 0' TERM while :; do sleep 0.1; done SH -bash "$TMP_ROOT/late-rewriter.sh" "$HOME/.claude.json" "$LATE/home" "$TMP_ROOT/late-child" >/dev/null 2>&1 & +start_group bash "$TMP_ROOT/late-rewriter.sh" "$HOME/.claude.json" "$LATE/home" "$TMP_ROOT/late-child" LATE_ROOT=$! printf '%s\n' "$LATE_ROOT" >> "$TMP_ROOT/pids" record_pid "$LATE" "$LATE_ROOT" @@ -440,7 +450,8 @@ pass "down tracks descendants spawned during TERM" # A reused PID with a different start time must not own its new process tree. Y=$(make_lab y claude) -bash -c 'sleep 600 & echo $! > "$1"; wait' _ "$TMP_ROOT/reused-child" & REUSED=$! +# shellcheck disable=SC2016 # Positional parameters expand in the launched shell. +start_group bash -c 'sleep 600 & echo $! > "$1"; wait' _ "$TMP_ROOT/reused-child"; REUSED=$! until [ -s "$TMP_ROOT/reused-child" ]; do sleep 0.1; done REUSED_CHILD=$(cat "$TMP_ROOT/reused-child") printf '%s\n%s\n' "$REUSED" "$REUSED_CHILD" >> "$TMP_ROOT/pids" @@ -454,7 +465,7 @@ pass "down ignores roots with mismatched start times" # Simulate a recorded PID changing identity after TERM: the first process # snapshot matches its start time, subsequent snapshots describe a reused PID. Z=$(make_lab z claude) -python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' & +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' REPLACED=$! printf '%s\n' "$REPLACED" >> "$TMP_ROOT/pids" record_pid "$Z" "$REPLACED" From f51ad540050f3243bb632df83b9a15388024ea71 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 20:34:09 -0700 Subject: [PATCH 17/19] no-mistakes(ci): Fixed teardown so an observed-empty process group is permanently dropped, preventing a reused group ID from signalling unrelated work. Added a ps-shim regression test. The lab test, lint, and diff checks pass --- bin/fm-live-lab.sh | 30 ++++++++++++++++++++++++++---- tests/fm-live-lab.test.sh | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 0041e2a0b66..f1571e049c9 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -634,7 +634,29 @@ expand_pairs() { if (p in owned) { print pid "\t" start[pid]; break } } } - }' <(printf '%s\n' "$1") <(ps -axo pid=,ppid=,pgid=,stat=,lstart=) + }' <(printf '%s\n' "$1") <(printf '%s\n' "$3") +} + +# A group is eligible only while each scan still sees an identity-valid member. +# Once absent, it is removed from the caller's group list and cannot be rediscovered. +prune_groups() { + awk -v groups="$1" ' + BEGIN { n=split(groups, ids, /[[:space:]]+/) } + NR==FNR { split($0, fields, "\t"); if (fields[1] ~ /^[0-9]+$/) saved[fields[1]]=fields[2]; next } + { + pid=$1; pgid=$3; state=$4 + start=$5 " " $6 " " $7 " " $8 " " $9 + if (state !~ /^Z/ && (!(pid in saved) || saved[pid] == start)) live[pgid]=1 + } + END { for (i=1; i<=n; i++) if (ids[i] in live) printf "%s ", ids[i] } + ' <(printf '%s\n' "$2") <(printf '%s\n' "$3") +} + +refresh_pairs() { + local snapshot + snapshot=$(ps -axo pid=,ppid=,pgid=,stat=,lstart=) + pairs=$(expand_pairs "$pairs" "$groups" "$snapshot") + groups=$(prune_groups "$groups" "$pairs" "$snapshot") } # pairs captured before tmux shutdown. Recheck identity even @@ -716,14 +738,14 @@ cmd_down() { [ "$pgid" = "$own_group" ] || [ "$pgid" = "$caller_group" ] || groups+="$pgid " done lab_tmux kill-server 2>/dev/null || true - pairs=$(expand_pairs "$pairs" "$groups") + refresh_pairs survivors=$(live_pids "$pairs") if [ -n "$survivors" ]; then # shellcheck disable=SC2086 # One identity-checked pid per word. kill $survivors 2>/dev/null || true fi for n in {1..40}; do - pairs=$(expand_pairs "$pairs" "$groups") + refresh_pairs survivors=$(live_pids "$pairs") [ -n "$survivors" ] || break if [ "$n" -eq 20 ]; then @@ -732,7 +754,7 @@ cmd_down() { fi sleep 0.5 done - pairs=$(expand_pairs "$pairs" "$groups") + refresh_pairs survivors=$(live_pids "$pairs") [ -z "$survivors" ] || die "refusing to remove the lab: its processes did not exit: $(echo "$survivors" | tr '\n' ' ')" echo "stopped: lab tmux server and lab processes" diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 6f5c8c16352..f21f0900eb8 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -462,6 +462,45 @@ kill -0 "$REUSED" 2>/dev/null || fail "down killed a reused PID" kill -0 "$REUSED_CHILD" 2>/dev/null || fail "down killed the reused PID's child" pass "down ignores roots with mismatched start times" +# A group observed empty must not be admitted again if its id is later reused. +# The ps shim hides the first group's only member on pass 2, then presents an +# unrelated process under that pgid on pass 3 while another lab group waits. +GROUP_REUSE=$(make_lab group-reuse claude) +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' +GROUP_ROOT=$! +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' +WAIT_ROOT=$! +sleep 0.2 +printf '%s\n%s\n' "$GROUP_ROOT" "$WAIT_ROOT" >> "$TMP_ROOT/pids" +record_pid "$GROUP_REUSE" "$GROUP_ROOT" +record_pid "$GROUP_REUSE" "$WAIT_ROOT" +sleep 600 >/dev/null 2>&1 & +GROUP_OUTSIDER=$! +printf '%s\n' "$GROUP_OUTSIDER" >> "$TMP_ROOT/pids" +GROUP_ID=$(ps -o pgid= -p "$GROUP_ROOT" | awk '{$1=$1; print}') +mkdir -p "$TMP_ROOT/group-ps-bin" +cat > "$TMP_ROOT/group-ps-bin/ps" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -axo ] && [ "${2:-}" = 'pid=,ppid=,pgid=,stat=,lstart=' ]; then + count=$(cat "$PS_SCAN_COUNT" 2>/dev/null || echo 0) + count=$((count + 1)) + echo "$count" > "$PS_SCAN_COUNT" + "$REAL_PS" "$@" | awk -v scan="$count" -v root="$PS_GROUP_ROOT" -v outsider="$PS_OUTSIDER" -v group="$PS_GROUP_ID" ' + scan >= 2 && $1 == root { next } + scan >= 3 && $1 == outsider { $3=group } + { print } + ' +else + "$REAL_PS" "$@" +fi +SH +chmod +x "$TMP_ROOT/group-ps-bin/ps" +out=$(REAL_PS="$(command -v ps)" PS_SCAN_COUNT="$TMP_ROOT/group-scan-count" PS_GROUP_ROOT="$GROUP_ROOT" PS_OUTSIDER="$GROUP_OUTSIDER" PS_GROUP_ID="$GROUP_ID" PATH="$TMP_ROOT/group-ps-bin:$PATH" "$LIVE_LAB" down "$GROUP_REUSE" 2>&1) +expect_code 0 "$?" "down ignores a reused group id: $out" +[ "$(cat "$TMP_ROOT/group-scan-count")" -ge 3 ] || fail "fixture did not expose the reused group id" +kill -0 "$GROUP_OUTSIDER" 2>/dev/null || fail "down signalled an unrelated process with a reused group id" +pass "down drops empty groups permanently before their ids can be reused" + # Simulate a recorded PID changing identity after TERM: the first process # snapshot matches its start time, subsequent snapshots describe a reused PID. Z=$(make_lab z claude) From 25ef89f949a4a2b85296b1be424aade44138402b Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 21:19:02 -0700 Subject: [PATCH 18/19] no-mistakes(ci): Fixed ci-1 in bin/fm-live-lab.sh and tests/fm-live-lab.test.sh. The TERM-born-child fixture now waits until its handler is installed before calling down. Down sends SIGKILL to identity-valid survivors on every pass from pass 20 onward and includes survivor process details if it must refuse cleanup. bin/fm-lint.sh and tests/fm-live-lab.test.sh pass locally; Linux CI remains to be verified --- bin/fm-live-lab.sh | 14 ++++++++++---- tests/fm-live-lab.test.sh | 6 ++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index f1571e049c9..2e2fd8e6340 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -723,7 +723,7 @@ NODE cmd_down() { load_lab "${1:-}" - local rc=0 pids pairs pid start survivors n removed added id meta dir home_hash groups pgid own_group caller_group + local rc=0 pids pairs pid start survivors n removed added id meta dir home_hash groups pgid own_group caller_group details local -a ids=() pids=$(lab_pids) pairs='' groups='' @@ -748,15 +748,21 @@ cmd_down() { refresh_pairs survivors=$(live_pids "$pairs") [ -n "$survivors" ] || break - if [ "$n" -eq 20 ]; then - # shellcheck disable=SC2086 # One pid per word. + if [ "$n" -ge 20 ]; then + # shellcheck disable=SC2086 # One identity-checked pid per word. kill -9 $survivors 2>/dev/null || true fi sleep 0.5 done refresh_pairs survivors=$(live_pids "$pairs") - [ -z "$survivors" ] || die "refusing to remove the lab: its processes did not exit: $(echo "$survivors" | tr '\n' ' ')" + if [ -n "$survivors" ]; then + details='' + for pid in $survivors; do + details+="$(ps -o pid=,ppid=,pgid=,stat=,command= -p "$pid" 2>/dev/null)"$'\n' + done + die "refusing to remove the lab: its processes did not exit (pid ppid pgid state command): $details" + fi echo "stopped: lab tmux server and lab processes" # A spawn keeps /tmp/fm- and /tmp/fm-+. # The second is scoped to this lab home for any task it spawned; the first is diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index f21f0900eb8..22006ba47f6 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -432,11 +432,17 @@ LATE=$(make_lab late claude) cat > "$TMP_ROOT/late-rewriter.sh" <<'SH' store=$1 key=$2 marker=$3 trap 'bash -c '\''sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$1" "$2"'\'' _ "$store" "$key" >/dev/null 2>&1 & echo $! > "$marker"; exit 0' TERM +echo ready > "$marker.ready" while :; do sleep 0.1; done SH start_group bash "$TMP_ROOT/late-rewriter.sh" "$HOME/.claude.json" "$LATE/home" "$TMP_ROOT/late-child" LATE_ROOT=$! printf '%s\n' "$LATE_ROOT" >> "$TMP_ROOT/pids" +for n in {1..50}; do + [ -s "$TMP_ROOT/late-child.ready" ] && break + sleep 0.1 +done +assert_present "$TMP_ROOT/late-child.ready" "TERM fixture installed its handler before down" record_pid "$LATE" "$LATE_ROOT" out=$("$LIVE_LAB" down "$LATE" 2>&1) expect_code 0 "$?" "down waits for a child born during TERM: $out" From 95d7b2b955e95e567ffc552f5461a6fe978c27cd Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Mon, 28 Sep 2026 21:54:03 -0700 Subject: [PATCH 19/19] =?UTF-8?q?no-mistakes(ci):=20Fixed=20down=E2=80=99s?= =?UTF-8?q?=20teardown=20wait=20to=20require=20two=20empty=20identity-chec?= =?UTF-8?q?ked=20scans=20separated=20by=200.5=20seconds,=20and=20removed?= =?UTF-8?q?=20the=20unused=20test=20loop=20variable=20without=20changing?= =?UTF-8?q?=20the=20TERM-born-child=20test.=20The=20lab=20test,=20lint,=20?= =?UTF-8?q?and=20diff=20check=20pass=20locally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/fm-live-lab.sh | 7 ++++++- tests/fm-live-lab.test.sh | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 2e2fd8e6340..752bf25a0cf 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -747,7 +747,12 @@ cmd_down() { for n in {1..40}; do refresh_pairs survivors=$(live_pids "$pairs") - [ -n "$survivors" ] || break + if [ -z "$survivors" ]; then + sleep 0.5 + refresh_pairs + survivors=$(live_pids "$pairs") + [ -n "$survivors" ] || break + fi if [ "$n" -ge 20 ]; then # shellcheck disable=SC2086 # One identity-checked pid per word. kill -9 $survivors 2>/dev/null || true diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 22006ba47f6..1e5b957dd37 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -438,7 +438,7 @@ SH start_group bash "$TMP_ROOT/late-rewriter.sh" "$HOME/.claude.json" "$LATE/home" "$TMP_ROOT/late-child" LATE_ROOT=$! printf '%s\n' "$LATE_ROOT" >> "$TMP_ROOT/pids" -for n in {1..50}; do +for ((attempt=0; attempt<50; attempt++)); do [ -s "$TMP_ROOT/late-child.ready" ] && break sleep 0.1 done