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
2 changes: 1 addition & 1 deletion bin/fm-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
50 changes: 50 additions & 0 deletions bin/fm-stat-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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 <path>
#
# 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 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
# 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 <path>: 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
}
14 changes: 5 additions & 9 deletions bin/fm-supervision-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <state-dir> [grace-seconds]
# Populates, for the state dir at $1:
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-crew-state.sh` | Print one deterministic current-state line for a crew |
| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification |
| `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate |
| `fm-stat-lib.sh` | Shared capability-probed portable file-mtime read (`fm_stat_mtime`), never OS-branched |
| `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs |
| `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync |
| `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation |
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() { # <name> -> echoes root dir (root/bin/<script> is the entry point)
Expand Down
48 changes: 48 additions & 0 deletions tests/fm-stat-lib.test.sh
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions tests/fm-turnend-guard.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down