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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 53 additions & 5 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <request_id>` |
| `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 <path>` to the opener; reads text from an argument, stdin, or `--text-file` |
Expand Down
6 changes: 3 additions & 3 deletions tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
Expand All @@ -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")"
Expand All @@ -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")"
Expand Down
150 changes: 150 additions & 0 deletions tests/fm-lock.test.sh
Original file line number Diff line number Diff line change
@@ -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" <<SH
#!/usr/bin/env bash
case "\$*" in
*"pid=,ppid=,comm=,args="*) printf '%s\n' '$rows'; exit 0 ;;
*"comm="*) printf '%s\n' bash; exit 0 ;;
*"args="*) printf '%s\n' 'bash bin/fm-lock.sh'; exit 0 ;;
*"ppid="*) printf '%s\n' 1; exit 0 ;;
esac
exit 1
SH
chmod +x "$fakebin/ps"
}

add_proc_cwd() {
local proc=$1 pid=$2 cwd=$3
mkdir -p "$proc/$pid"
ln -s "$cwd" "$proc/$pid/cwd"
}

test_unique_root_harness_acquires_lock() {
local rec root home proc fakebin out
rec=$(make_world unique)
IFS='|' read -r root home proc fakebin <<EOF
$rec
EOF
make_detached_ps "$fakebin" '42 1 codex /usr/local/bin/codex'
add_proc_cwd "$proc" 42 "$root"

out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_PROC_ROOT="$proc" PATH="$fakebin:$PATH" "$LOCK")

assert_contains "$out" "lock acquired: harness pid 42" "unique root harness did not acquire the lock"
assert_grep '42' "$home/state/.lock" "unique root harness pid was not written"
pass "fm-lock accepts one detached harness rooted at firstmate"
}

test_direct_harness_beats_interpreter_wrapper() {
local rec root home proc fakebin out
rec=$(make_world wrapper)
IFS='|' read -r root home proc fakebin <<EOF
$rec
EOF
make_detached_ps "$fakebin" $'50 1 node node /opt/codex\n51 50 codex /opt/vendor/codex'
add_proc_cwd "$proc" 50 "$root"
add_proc_cwd "$proc" 51 "$root"

out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_PROC_ROOT="$proc" PATH="$fakebin:$PATH" "$LOCK")

assert_contains "$out" "lock acquired: harness pid 51" "direct harness did not beat its interpreter wrapper"
pass "fm-lock prefers the direct harness process over a wrapper"
}

test_multiple_root_harnesses_fail_closed() {
local rec root home proc fakebin out status=0
rec=$(make_world multiple)
IFS='|' read -r root home proc fakebin <<EOF
$rec
EOF
make_detached_ps "$fakebin" $'60 1 codex /opt/codex\n61 1 codex /opt/codex'
add_proc_cwd "$proc" 60 "$root"
add_proc_cwd "$proc" 61 "$root"

out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_PROC_ROOT="$proc" PATH="$fakebin:$PATH" "$LOCK" 2>&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 <<EOF
$rec
EOF
other="$TMP_ROOT/other-root/project"
mkdir -p "$other"
make_detached_ps "$fakebin" '70 1 codex /opt/codex'
add_proc_cwd "$proc" 70 "$other"

out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_PROC_ROOT="$proc" PATH="$fakebin:$PATH" "$LOCK" 2>&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 <<EOF
$rec
EOF
make_detached_ps "$fakebin" $'80 1 codex /opt/codex\n81 1 node node /opt/claude/bin/claude.js'
add_proc_cwd "$proc" 80 "$root"
add_proc_cwd "$proc" 81 "$root"

out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_PROC_ROOT="$proc" PATH="$fakebin:$PATH" "$LOCK" 2>&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 <<EOF
$rec
EOF
make_detached_ps "$fakebin" '90 1 node node /opt/runner.js --model codex'
add_proc_cwd "$proc" 90 "$root"

out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_PROC_ROOT="$proc" PATH="$fakebin:$PATH" "$LOCK" 2>&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
9 changes: 4 additions & 5 deletions tests/fm-session-start.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"

Expand Down Expand Up @@ -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"

Expand All @@ -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"

Expand Down