From 48e8587dbd8e4f0d79b9fe4cd744c0de282493fc Mon Sep 17 00:00:00 2001 From: Kenny Gatdula Date: Sat, 18 Jul 2026 06:52:29 -0400 Subject: [PATCH 1/4] fix(supervision): capability-probe stat flavor instead of uname branch GNU coreutils stat ahead of /usr/bin/stat on PATH (a common Homebrew layout) makes the old uname=Darwin branch pick the BSD `-f` form, which GNU stat reads as its filesystem-mode flag and answers with a multi-line "File: ..." dump. Under set -u that stray string crashed the watcher with "File: unbound variable" and took supervision down (issue #464). Add bin/fm-stat-lib.sh as the single owner of a portable file-mtime read that detects GNU vs BSD stat by probing `stat -c %Y /` once, never by OS. Rewire bin/fm-supervision-lib.sh (the reported crash site) to source it. The other uname-based stat helpers are deliberately left for follow-ups so this stays a single-hot-file change. --- bin/fm-stat-lib.sh | 48 ++++++++++++++++++++++++++++++++++ bin/fm-supervision-lib.sh | 14 ++++------ tests/fm-stat-lib.test.sh | 48 ++++++++++++++++++++++++++++++++++ tests/fm-turnend-guard.test.sh | 1 + 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 bin/fm-stat-lib.sh create mode 100755 tests/fm-stat-lib.test.sh diff --git a/bin/fm-stat-lib.sh b/bin/fm-stat-lib.sh new file mode 100644 index 0000000000..26464da2bb --- /dev/null +++ b/bin/fm-stat-lib.sh @@ -0,0 +1,48 @@ +# shellcheck shell=bash +# bin/fm-stat-lib.sh - the single owner of firstmate's portable file-mtime read. +# +# Usage: . bin/fm-stat-lib.sh ; fm_stat_mtime +# +# WHY THIS EXISTS (upstream issue #464): the supervision and guard path needs a +# file's modification time as a bare epoch second, but the two stat flavors +# disagree on the flag - GNU coreutils stat uses `-c %Y`, BSD/macOS stat uses +# `-f %m`. The historical helpers branched on `uname`, choosing the BSD `-f` +# form on Darwin. That misfires when GNU coreutils' stat is ahead of +# /usr/bin/stat on PATH (a common Homebrew layout): GNU stat reads `-f` as its +# filesystem-mode flag and prints a multi-line "File: ..." dump instead of an +# epoch, and under `set -u` that stray string crashed the watcher with +# "File: unbound variable" and took supervision down. +# +# THE FIX: detect the stat flavor by CAPABILITY, never by OS. Probe `stat -c %Y` +# once against a path guaranteed to be statable (`/`); if that yields a bare +# number this stat speaks GNU and we keep `-c %Y`, otherwise we use the BSD +# `-f %m` form. The result is cached in FM_STAT_FLAVOR so detection runs at most +# once per shell. +# +# fm_stat_mtime prints the mtime as epoch seconds on success and nothing (with a +# non-zero exit) when the path cannot be stat'd, matching the old per-helper form +# so callers keep their existing empty-string checks. + +FM_STAT_FLAVOR=${FM_STAT_FLAVOR:-} + +# fm_stat_detect_flavor: probe the on-PATH stat once and cache gnu/bsd in +# FM_STAT_FLAVOR. GNU stat answers `-c %Y /` with a bare epoch; BSD stat rejects +# `-c` and prints nothing to stdout, so a non-numeric probe means BSD. +fm_stat_detect_flavor() { + local probe + probe=$(stat -c %Y / 2>/dev/null) + case "$probe" in + ''|*[!0-9]*) FM_STAT_FLAVOR=bsd ;; + *) FM_STAT_FLAVOR=gnu ;; + esac +} + +# fm_stat_mtime : echo the file's mtime as epoch seconds, nothing on error. +fm_stat_mtime() { + [ -n "$FM_STAT_FLAVOR" ] || fm_stat_detect_flavor + if [ "$FM_STAT_FLAVOR" = gnu ]; then + stat -c %Y "$1" 2>/dev/null + else + stat -f %m "$1" 2>/dev/null + fi +} diff --git a/bin/fm-supervision-lib.sh b/bin/fm-supervision-lib.sh index c89747a94f..c2922cd317 100644 --- a/bin/fm-supervision-lib.sh +++ b/bin/fm-supervision-lib.sh @@ -9,14 +9,10 @@ # fields here for its banner but performs its end-of-turn block decision with the # live watcher lock check in bin/fm-wake-lib.sh. -# Portable mtime; Linux stat lacks -f, macOS stat lacks -c. -fm_sup_stat_mtime() { - if [ "$(uname)" = Darwin ]; then - stat -f %m "$1" 2>/dev/null - else - stat -c %Y "$1" 2>/dev/null - fi -} +# Portable file-mtime read (fm_stat_mtime), capability-probed rather than +# OS-branched; see bin/fm-stat-lib.sh for why the uname branch was unsafe. +# shellcheck source=bin/fm-stat-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/fm-stat-lib.sh" # fm_supervision_status [grace-seconds] # Populates, for the state dir at $1: @@ -40,7 +36,7 @@ fm_supervision_status() { beat="$state/.last-watcher-beat" if [ -e "$beat" ]; then - m=$(fm_sup_stat_mtime "$beat") + m=$(fm_stat_mtime "$beat") if [ -n "$m" ]; then age=$(( $(date +%s) - m )) FM_SUP_BEACON_DESC="${age}s ago" diff --git a/tests/fm-stat-lib.test.sh b/tests/fm-stat-lib.test.sh new file mode 100755 index 0000000000..30f66a459c --- /dev/null +++ b/tests/fm-stat-lib.test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# tests/fm-stat-lib.test.sh - unit tests for the portable, capability-probed +# file-mtime helper (bin/fm-stat-lib.sh). Pure functions, no backend required. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=bin/fm-stat-lib.sh +. "$ROOT/bin/fm-stat-lib.sh" + +# --- flavor detection -------------------------------------------------------- + +fm_stat_detect_flavor +case "$FM_STAT_FLAVOR" in + gnu|bsd) : ;; + *) fail "fm_stat_detect_flavor must cache gnu or bsd, got '$FM_STAT_FLAVOR'" ;; +esac +pass "fm_stat_detect_flavor resolves to a known stat flavor ($FM_STAT_FLAVOR)" + +# --- mtime on a real file returns a bare epoch second ------------------------ + +TMP=$(fm_test_tmproot fm-stat) +mkdir -p "$TMP" +F="$TMP/probe" +: > "$F" + +M=$(fm_stat_mtime "$F") +case "$M" in + ''|*[!0-9]*) fail "fm_stat_mtime must return bare epoch seconds, got '$M'" ;; + *) : ;; +esac +pass "fm_stat_mtime returns a bare epoch second on this platform" + +# The reported mtime is within a sane window of the wall clock (a real read, not +# a stray "File: ..." dump from the wrong stat flavor). +NOW=$(date +%s) +DELTA=$(( NOW - M )) +[ "$DELTA" -ge -5 ] && [ "$DELTA" -le 300 ] || fail "mtime $M implausible vs now $NOW (delta ${DELTA}s)" +pass "fm_stat_mtime tracks the actual file mtime, not a foreign stat dump" + +# --- missing path fails quietly --------------------------------------------- + +MISSING=$(fm_stat_mtime "$TMP/does-not-exist") +[ -z "$MISSING" ] || fail "fm_stat_mtime must print nothing for a missing path, got '$MISSING'" +pass "fm_stat_mtime prints nothing for a path it cannot stat" + +echo "# fm-stat-lib.test.sh: all assertions passed" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index d71f50bb0c..e801f41ff6 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -92,6 +92,7 @@ install_guard_scripts() { cp "$ROOT/bin/fm-harness.sh" "$dir/bin/fm-harness.sh" cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" + cp "$ROOT/bin/fm-stat-lib.sh" "$dir/bin/fm-stat-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" mkdir -p "$dir/docs" cp -R "$ROOT/docs/supervision-protocols" "$dir/docs/supervision-protocols" From 23b0f0f3f56f8bf0615efafbcb507f251f78d85f Mon Sep 17 00:00:00 2001 From: Kenny Gatdula Date: Sat, 18 Jul 2026 06:59:55 -0400 Subject: [PATCH 2/4] no-mistakes(review): fix fm-guard stat call and correct stat-lib cache comment --- bin/fm-guard.sh | 2 +- bin/fm-stat-lib.sh | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 024491defc..15b844f307 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -48,7 +48,7 @@ fm_guard_stale_episode_key() { local state=$1 beat m beat="$state/.last-watcher-beat" if [ -e "$beat" ]; then - m=$(fm_sup_stat_mtime "$beat") + m=$(fm_stat_mtime "$beat") printf 'beat:%s\n' "${m:-unknown}" else printf 'beat:absent\n' diff --git a/bin/fm-stat-lib.sh b/bin/fm-stat-lib.sh index 26464da2bb..a6c901385c 100644 --- a/bin/fm-stat-lib.sh +++ b/bin/fm-stat-lib.sh @@ -16,8 +16,10 @@ # THE FIX: detect the stat flavor by CAPABILITY, never by OS. Probe `stat -c %Y` # once against a path guaranteed to be statable (`/`); if that yields a bare # number this stat speaks GNU and we keep `-c %Y`, otherwise we use the BSD -# `-f %m` form. The result is cached in FM_STAT_FLAVOR so detection runs at most -# once per shell. +# `-f %m` form. The probe is a single cheap stat. The FM_STAT_FLAVOR cache avoids +# re-probing only within the same shell scope, so a caller that invokes +# fm_stat_mtime through command substitution re-probes once per call, which is +# negligible. # # fm_stat_mtime prints the mtime as epoch seconds on success and nothing (with a # non-zero exit) when the path cannot be stat'd, matching the old per-helper form From f54905da57845bfe82ee61aa2f64c5d3aa899812 Mon Sep 17 00:00:00 2001 From: Kenny Gatdula Date: Sat, 18 Jul 2026 07:21:16 -0400 Subject: [PATCH 3/4] no-mistakes(test): copy fm-stat-lib.sh into fm-backend test's old bin --- tests/fm-backend.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index c25bf8a225..203473e1e1 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -110,7 +110,7 @@ BASE_REF=$(resolve_base_ref) \ # tmux-only conformance run the tmux adapter's behavior is what is under test, # and that is unchanged by any later (e.g. non-tmux backend) addition to # fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-stat-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh" build_old_bin() { # -> echoes root dir (root/bin/