diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3bbefab2b..e48d7a79be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,6 +84,7 @@ tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only comp tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry) tests/fm-afk-inject-herdr-e2e.test.sh # real-herdr end-to-end test of the afk daemon's herdr transport, on an isolated throwaway HERDR_SESSION: partial-input deferral, swallowed-Enter retry, a normal digest, and the max-defer wedge alarm on a persistently pending composer tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, fleet-sync timeout, and crew-dispatch reporting tests +tests/fm-lock.test.sh # session-lock ancestry and detached-runner fallback: unique repo-root harness acceptance, wrapper deduplication, and ambiguous or unrelated candidate refusal tests/fm-session-start.test.sh # fm-session-start.sh: ABSENT vs empty-vs-present digest files, lock-refusal read-only path skipping every mutating step, diagnostics-first section ordering, status-tail bounding, tmux/herdr endpoint liveness, and composition of the real fm-lock/fm-bootstrap/fm-wake-drain scripts tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard, teardown cleanup, and session-lock detection tests tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, single-project name resolution, and bootstrap relay diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 33e4b0d279..bd126aeb7f 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Acquire or inspect the per-home firstmate session lock. -# Writes the harness (agent) process PID found by walking the shell's ancestry, -# which lives as long as the firstmate session - unlike the transient subshell -# PID of any one tool call, which is dead moments after it is written. +# Writes the harness (agent) process PID found by walking the shell's ancestry. +# Detached tool runners fall back to the single harness whose cwd is this repo. +# That PID lives as long as the firstmate session, unlike a transient subshell. # Usage: fm-lock.sh acquire; exit 1 if another live session holds it # fm-lock.sh status print holder and liveness; always exits 0 set -u @@ -16,6 +16,52 @@ mkdir -p "$STATE" # Known harness command names; extend when a new adapter is verified. HARNESS_RE='claude|codex|opencode|grok|^pi$' +PROC_ROOT=${FM_PROC_ROOT:-/proc} + +root_harness_pid() { + local pid ppid comm args cwd name entrypoint harness candidates='' + local -a direct_pids=() interpreted_pids=() + local -A direct_names=() direct_parents=() interpreted_names=() + while read -r pid ppid comm args; do + [ -n "$pid" ] || continue + cwd=$(readlink -f "$PROC_ROOT/$pid/cwd" 2>/dev/null) || continue + [ "$cwd" = "$FM_ROOT" ] || continue + name=$(basename "$comm") + case "$name" in + claude|codex|opencode|grok|pi) + direct_pids+=("$pid") + direct_names["$pid"]=$name + direct_parents["$pid"]=$ppid + ;; + node|nodejs|python|python3) + read -r _ entrypoint _ <<< "$args" + [ -n "${entrypoint:-}" ] || continue + harness=$(printf '%s\n' "$entrypoint" | grep -oE '(^|/)(claude|codex|opencode|grok|pi)(\.[^/]*)?($|/)' | head -n 1 | sed -E 's#^/##; s#/.*$##; s/\..*$//') + [ -n "$harness" ] || continue + interpreted_pids+=("$pid") + interpreted_names["$pid"]=$harness + ;; + esac + done < <(ps -eo pid=,ppid=,comm=,args= 2>/dev/null) + + for pid in "${direct_pids[@]}"; do + candidates="$candidates${candidates:+ }$pid" + done + for pid in "${interpreted_pids[@]}"; do + local wrapped=0 direct_pid + for direct_pid in "${direct_pids[@]}"; do + if [ "${direct_parents[$direct_pid]}" = "$pid" ] && [ "${direct_names[$direct_pid]}" = "${interpreted_names[$pid]}" ]; then + wrapped=1 + break + fi + done + [ "$wrapped" -eq 1 ] || candidates="$candidates${candidates:+ }$pid" + done + # shellcheck disable=SC2086 # Intentional word splitting counts candidate PIDs. + set -- $candidates + [ "$#" -eq 1 ] || return 1 + printf '%s\n' "$1" +} harness_pid() { local pid=$$ comm args @@ -30,9 +76,11 @@ harness_pid() { *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;; esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 + if [ -z "$pid" ] || [ "$pid" -le 1 ]; then + break + fi done - return 1 + root_harness_pid } holder_alive() { # true if $1 is a live process that looks like a harness diff --git a/docs/scripts.md b/docs/scripts.md index 4d2070a36c..fada076da8 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -53,7 +53,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | | `fm-teardown.sh` | Return a clean, landed ship worktree or retire/release a secondmate home; requires scout reports, checks child work, tolerates only provably stale worktree git locks, removes firstmate-owned hook artifacts, closes recorded backend endpoints under their owning home context, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | | `fm-harness.sh` | Detect the running harness; resolve the effective crewmate (`crew`) or secondmate-launch (`secondmate`) harness; expose optional `config/secondmate-harness` model and effort tokens with `secondmate-model` and `secondmate-effort` | -| `fm-lock.sh` | Per-home firstmate session lock | +| `fm-lock.sh` | Per-home firstmate session lock; identifies the holder through process ancestry, or for a detached tool runner accepts only the single recognized harness whose working directory is the firstmate repo and refuses ambiguous or unrelated candidates | | `fm-x-lib.sh` | Shared X-mode `.env`, alternate env-file, relay, dry-run config, reply-thread splitting, outbound image payloads, and task-to-X-request meta-link helpers | | `fm-x-poll.sh` | Do one bounded X relay poll; without `FMX_PAIRING_TOKEN` it is silent, with a pending mention it stashes the full inbox JSON, including `in_reply_to`, and prints `x-mention ` | | `fm-x-reply.sh` | Post or dry-run preview a composed public-safe X answer or `--followup`, auto-splitting long text into `{request_id,text,texts}` threads and optionally attaching `--image ` to the opener; reads text from an argument, stdin, or `--text-file` | diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 58779621e3..940096f6a1 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -650,7 +650,7 @@ test_send_conformance_old_vs_new() { expect_code "$rc_old" "$rc_new" "fm-send --key: old vs new exit code" assert_contains "$(cat "$log_new")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ "fm-send --key did not verify the explicit tmux target before sending" - cp "$log_old" "$filtered_old" + strip_send_preflight "$log_old" > "$filtered_old" strip_send_preflight "$log_new" > "$filtered_new" diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-key.txt" 2>&1 \ || fail "fm-send --key: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-key.txt")" @@ -662,7 +662,7 @@ test_send_conformance_old_vs_new() { run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" hello captain rc_new=$? expect_code "$rc_old" "$rc_new" "fm-send plain text: old vs new exit code" - cp "$log_old" "$filtered_old" + strip_send_preflight "$log_old" > "$filtered_old" strip_send_preflight "$log_new" > "$filtered_new" diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-plain.txt" 2>&1 \ || fail "fm-send plain text: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-plain.txt")" @@ -678,7 +678,7 @@ test_send_conformance_old_vs_new() { run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" /some-skill rc_new=$? expect_code "$rc_old" "$rc_new" "fm-send /skill: old vs new exit code" - cp "$log_old" "$filtered_old" + strip_send_preflight "$log_old" > "$filtered_old" strip_send_preflight "$log_new" > "$filtered_new" diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-slash.txt" 2>&1 \ || fail "fm-send /skill: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-slash.txt")" diff --git a/tests/fm-lock.test.sh b/tests/fm-lock.test.sh new file mode 100755 index 0000000000..f17348136f --- /dev/null +++ b/tests/fm-lock.test.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# tests/fm-lock.test.sh - behavior tests for the primary session lock. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +LOCK="$ROOT/bin/fm-lock.sh" +TMP_ROOT=$(fm_test_tmproot fm-lock-tests) + +make_world() { + local name=$1 world root home proc fakebin + world="$TMP_ROOT/$name" + root="$world/root" + home="$world/home" + proc="$world/proc" + fakebin=$(fm_fakebin "$world") + mkdir -p "$root" "$home/state" "$proc" + printf '%s\n' "$root|$home|$proc|$fakebin" +} + +make_detached_ps() { + local fakebin=$1 rows=$2 + cat > "$fakebin/ps" <&1) || status=$? + + expect_code 1 "$status" "multiple root harnesses must fail closed" + assert_contains "$out" "cannot locate harness process in ancestry" "ambiguous fallback did not use the safe refusal" + assert_absent "$home/state/.lock" "ambiguous fallback wrote a lock" + pass "fm-lock rejects multiple detached harnesses in the same root" +} + +test_other_root_harness_is_ignored() { + local rec root home proc fakebin other out status=0 + rec=$(make_world other-root) + IFS='|' read -r root home proc fakebin <&1) || status=$? + + expect_code 1 "$status" "a harness in another root must not acquire this lock" + assert_contains "$out" "cannot locate harness process in ancestry" "other-root harness did not use the safe refusal" + assert_absent "$home/state/.lock" "other-root harness wrote a lock" + pass "fm-lock ignores detached harnesses rooted elsewhere" +} + +test_distinct_direct_and_interpreted_harnesses_fail_closed() { + local rec root home proc fakebin out status=0 + rec=$(make_world mixed) + IFS='|' read -r root home proc fakebin <&1) || status=$? + + expect_code 1 "$status" "distinct direct and interpreted harnesses must fail closed" + assert_absent "$home/state/.lock" "mixed harnesses wrote a lock" + pass "fm-lock rejects distinct direct and interpreted harnesses" +} + +test_unrelated_interpreter_argument_is_ignored() { + local rec root home proc fakebin out status=0 + rec=$(make_world unrelated) + IFS='|' read -r root home proc fakebin <&1) || status=$? + + expect_code 1 "$status" "an unrelated interpreter must not acquire the lock" + assert_absent "$home/state/.lock" "unrelated interpreter wrote a lock" + pass "fm-lock ignores harness words in interpreter arguments" +} + +test_unique_root_harness_acquires_lock +test_direct_harness_beats_interpreter_wrapper +test_multiple_root_harnesses_fail_closed +test_other_root_harness_is_ignored +test_distinct_direct_and_interpreted_harnesses_fail_closed +test_unrelated_interpreter_argument_is_ignored diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 3a6a16ffee..6e9395e563 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -256,8 +256,8 @@ $rec EOF make_fake_toolchain "$fakebin" make_fake_ps_claude "$fakebin" - # Force a MISSING diagnostic line so the bootstrap section is non-trivial. - rm -f "$fakebin/node" + # tasks-axi is deliberately absent from the controlled PATH, forcing a + # MISSING diagnostic while the fake toolchain supplies every other tool. printf 'window=fm-sess:w1\nkind=ship\n' > "$home/state/task-a.meta" @@ -280,7 +280,7 @@ EOF [ "$context_line" -lt "$fleet_line" ] || fail "CONTEXT did not precede FLEET STATE" [ "$fleet_line" -lt "$next_line" ] || fail "FLEET STATE did not precede NEXT STEP" - missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: node' | head -1 | cut -d: -f1) + missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: tasks-axi' | head -1 | cut -d: -f1) [ -n "$missing_line" ] || fail "MISSING diagnostic did not appear at all" [ "$missing_line" -lt "$fleet_line" ] || fail "actionable MISSING diagnostic was buried after the bulk fleet-state digest" @@ -400,7 +400,6 @@ $rec EOF make_fake_toolchain "$fakebin" make_fake_ps_claude "$fakebin" - rm -f "$fakebin/node" append_wake "$home/state" signal task-z "needs-decision: pick a library" @@ -409,7 +408,7 @@ EOF # fm-lock.sh's own exact success text. assert_contains "$out" "lock acquired: harness pid" "fm-lock.sh's real output did not appear (composition, not reimplementation)" # fm-bootstrap.sh's own exact MISSING-tool line format. - assert_contains "$out" "MISSING: node (install:" "fm-bootstrap.sh's real detect line did not appear verbatim" + assert_contains "$out" "MISSING: tasks-axi (install:" "fm-bootstrap.sh's real detect line did not appear verbatim" # fm-wake-drain.sh's real drained record (raw tab-separated queue line). assert_contains "$out" "$(printf 'signal\ttask-z\tneeds-decision: pick a library')" "fm-wake-drain.sh's real drained record did not appear"