Skip to content
Merged
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
6 changes: 3 additions & 3 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
# - AFK: while state/.afk exists the away daemon owns the watcher and triage;
# this hook exits 0 and NEVER rewakes the primary (checked again at
# translation time so a mid-cycle AFK transition is honored).
# - Need: arms only while work is in flight (state/*.meta) or X mode has a
# relay poll to run (state/x-watch.check.sh); an idle home exits 0.
# - Need: arms only while the home needs supervision, as
# bin/fm-supervision-lib.sh defines it; an idle home exits 0.
# - Single-flight: Claude does not dedupe async hooks, so exactly one
# GENERATION owner arms per event epoch: the epoch ledger's monotonic
# sequence is the claim generation, every firing defers (exit 0) to a live
Expand Down Expand Up @@ -129,7 +129,7 @@ fi
# --- AFK: the away daemon owns the watcher and triage; never rewake ----------
[ -e "$STATE/.afk" ] && exit 0

# --- need: in-flight work or an X-mode relay poll ----------------------------
# --- need: whatever bin/fm-supervision-lib.sh counts as supervision need ------
need_supervision() {
fm_supervision_needed "$STATE" "$GRACE"
}
Expand Down
13 changes: 8 additions & 5 deletions bin/fm-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
# First, always warn if the firstmate primary checkout (FM_ROOT) is on a named
# non-default branch, because that means firstmate-on-itself work landed in the
# primary instead of an isolated worktree.
# Then, if a task is in flight (a state/<id>.meta exists) or X-mode relay
# polling is active (state/x-watch.check.sh exists) and supervision is not
# healthy, prints a loud, clearly delimited banner so the agent cannot skim past
# Then, if the home needs supervision (bin/fm-supervision-lib.sh owns that
# condition set) and that supervision is not healthy, prints a loud, clearly
# delimited banner so the agent cannot skim past
# it in the tool output of whatever it was doing - the one channel every harness
# has. Supervision health is MODEL-AWARE (fm_watcher_supervision_verdict in
# bin/fm-wake-lib.sh): under the Claude Stop auto-arm model the watcher runs only
Expand Down Expand Up @@ -158,11 +158,12 @@ if [ -n "$tangle_branch" ]; then
fi

# Compute supervision need and watcher-beacon freshness via the shared
# grace-based predicate (bin/fm-supervision-lib.sh). Act when work, an event
# source, or an X-mode relay poll needs supervision.
# grace-based predicate (bin/fm-supervision-lib.sh), which owns what needs
# supervision.
fm_supervision_status "$STATE" "$GRACE"
in_flight=$FM_SUP_IN_FLIGHT
sources=$FM_SUP_SOURCES
checks=$FM_SUP_CHECKS
needed=$FM_SUP_NEEDED
beacon_desc=$FM_SUP_BEACON_DESC
fm_watcher_supervision_verdict "$STATE" "$WATCH" "$GRACE" "$FM_HOME" "$FM_ROOT"
Expand Down Expand Up @@ -216,6 +217,8 @@ if [ "$watcher_healthy" = false ]; then
printf '● %s task(s) in flight, but %s.\n' "$in_flight" "$watcher_cause"
elif [ "$sources" -gt 0 ]; then
printf '● %s process-event source(s) registered, but %s.\n' "$sources" "$watcher_cause"
elif [ "$checks" -gt 0 ]; then
printf '● %s registered custom check(s), but %s.\n' "$checks" "$watcher_cause"
else
printf '● X-mode relay polling needs supervision, but %s.\n' "$watcher_cause"
fi
Expand Down
39 changes: 31 additions & 8 deletions bin/fm-supervision-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
# Shared "supervision missing" predicate.
# Usage: . bin/fm-supervision-lib.sh
#
# Reports whether a firstmate home needs supervision because it has in-flight
# work (a state/<id>.meta exists) or an X-mode relay poll
# (state/x-watch.check.sh), and whether its watcher has a fresh liveness beacon
# (state/.last-watcher-beat, touched every poll cycle, within the grace window).
# Reports whether a firstmate home needs supervision (fm_supervision_status
# below is the single owner of that condition set), and whether its watcher has
# a fresh liveness beacon (state/.last-watcher-beat, touched every poll cycle,
# within the grace window).
# bin/fm-turnend-guard.sh uses the PID-strict fm_watcher_healthy from
# bin/fm-wake-lib.sh for its block decision. bin/fm-guard.sh uses the model-aware
# fm_watcher_supervision_verdict (also in bin/fm-wake-lib.sh), which owns what a
Expand All @@ -25,16 +25,27 @@ fm_sup_stat_mtime() {
# Populates, for the state dir at $1:
# FM_SUP_IN_FLIGHT count of state/*.meta (in-flight tasks)
# FM_SUP_SOURCES count of registered process-to-event sources
# FM_SUP_NEEDED true/false - in-flight work, an X-mode relay poll, or a
# FM_SUP_CHECKS count of registered custom checks: a state/<id>.check.sh
# with the state/<id>.check-trust binding that
# bin/fm-check-register.sh writes. Task PR polls carry no
# such binding and are torn down with their task, and the
# relay shim keeps its own trust path, so neither counts
# here. Presence of the binding is the whole test: whether
# those bytes are still the registered ones is the check
# sweep's call at execution time, and a home whose check
# no longer validates needs the watcher precisely so the
# sweep can report the rejection instead of going quiet.
# FM_SUP_NEEDED true/false - in-flight work, an X-mode relay poll, a
# registered event source (a source is a wait on an
# external process, not a task, so it has no metadata)
# external process, not a task, so it has no metadata),
# or a registered custom check
# FM_SUP_WATCHER_FRESH true/false - a watcher beacon within the grace window
# FM_SUP_BEACON_DESC human-readable beacon age, for banners ("never" if absent)
# FM_SUP_QUEUE_PENDING true/false - state/.wake-queue has unread records
# grace-seconds defaults to $FM_GUARD_GRACE, then 300, matching fm-guard.sh.
# Always returns 0; callers read the vars, or use fm_supervision_unhealthy below.
fm_supervision_status() {
local state=$1 grace=${2:-${FM_GUARD_GRACE:-300}} meta source beat m age
local state=$1 grace=${2:-${FM_GUARD_GRACE:-300}} meta source check id beat m age
FM_SUP_IN_FLIGHT=0
FM_SUP_NEEDED=false
FM_SUP_WATCHER_FRESH=false
Expand All @@ -50,9 +61,21 @@ fm_supervision_status() {
[ -e "$source" ] || continue
FM_SUP_SOURCES=$((FM_SUP_SOURCES + 1))
done
FM_SUP_CHECKS=0
for check in "$state"/*.check.sh; do
[ -e "$check" ] || continue
id=${check##*/}
id=${id%.check.sh}
if [ "$id" = x-watch ]; then
continue
fi
[ -e "$state/$id.check-trust" ] || continue
FM_SUP_CHECKS=$((FM_SUP_CHECKS + 1))
done
if [ "$FM_SUP_IN_FLIGHT" -gt 0 ] \
|| [ -f "$state/x-watch.check.sh" ] \
|| [ "$FM_SUP_SOURCES" -gt 0 ]; then
|| [ "$FM_SUP_SOURCES" -gt 0 ] \
|| [ "$FM_SUP_CHECKS" -gt 0 ]; then
FM_SUP_NEEDED=true
fi

Expand Down
4 changes: 4 additions & 0 deletions bin/fm-turnend-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ block_stop() {
printf '● %s task(s) in flight, but no live watcher holds this home lock (last beat: %s).\n' "$FM_SUP_IN_FLIGHT" "$FM_SUP_BEACON_DESC"
elif [ "$FM_SUP_SOURCES" -gt 0 ]; then
printf '● %s process-event source(s) registered, but no live watcher holds this home lock (last beat: %s).\n' "$FM_SUP_SOURCES" "$FM_SUP_BEACON_DESC"
elif [ "$FM_SUP_CHECKS" -gt 0 ]; then
printf '● %s registered custom check(s), but no live watcher holds this home lock (last beat: %s).\n' "$FM_SUP_CHECKS" "$FM_SUP_BEACON_DESC"
else
printf '● X-mode relay polling needs supervision, but no live watcher holds this home lock (last beat: %s).\n' "$FM_SUP_BEACON_DESC"
fi
Expand Down Expand Up @@ -449,6 +451,8 @@ if [ "$terminal_status" -eq 0 ]; then
NEED_DESC="$FM_SUP_IN_FLIGHT task(s) in flight"
elif [ "$FM_SUP_SOURCES" -gt 0 ]; then
NEED_DESC="$FM_SUP_SOURCES process-event source(s) registered"
elif [ "$FM_SUP_CHECKS" -gt 0 ]; then
NEED_DESC="$FM_SUP_CHECKS registered custom check(s)"
else
NEED_DESC="X-mode relay polling active"
fi
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ It suppresses failed-looking closes when the same identity-matched watcher is he
Cursor's `bin/fm-turnend-guard-cursor.sh` hook is the same between-turns shape in one synchronous step: it parks the awaited `stop` hook on the arm wrapper and translates an actionable close into one `followup_message`, with a generation baton that makes an older park still running after the next `stop` claim stand down instead of leaking a stale duplicate wake.
The existing turn-end guard remains the final backstop for every harness-engine protocol, with pi-signed sharing Pi's protocol, the `--claude` mode cooperating with the auto-arm claim, and Cursor's `--cursor` mode rendering a block as one bounded follow-up because its `stop` step cannot be blocked.
Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers.
A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled or if work, process-event sources, or Relay polling has an unhealthy model-aware supervision verdict; on main it also warns when queued wakes are waiting to be drained.
A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled or if work, process-event sources, registered custom checks, or Relay polling has an unhealthy model-aware supervision verdict; on main it also warns when queued wakes are waiting to be drained.
The drain script calls that guard after presenting the queue; records remain durable until the exact generation-bound acknowledgement printed by the drain succeeds after handling, and main may keep the queued-wakes warning visible until then.
The Pi supervision branch's deliberate queued-wake warning exception is owned by [`pi-supervision-branch.md`](pi-supervision-branch.md#components-and-their-owners).
It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the watcher-down banner and reminder policy so repeated guarded commands stay noisy without reprinting the full banner in the same episode.
On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work, a process-event source, or Relay polling needs supervision and no supervision owner provably holds this home with a fresh beacon, blocking-capable Stop hooks block and nonblocking turn-end integrations force one bounded follow-up.
On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work, a process-event source, a registered custom check, or Relay polling needs supervision and no supervision owner provably holds this home with a fresh beacon, blocking-capable Stop hooks block and nonblocking turn-end integrations force one bounded follow-up.
The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md).

A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ See [`docs/examples/watched-tools.json`](examples/watched-tools.json) for a star

Arm the check once per home with `bin/fm-tool-update-check.sh arm`.
That writes `state/tool-updates.check.sh` and binds its bytes with `bin/fm-check-register.sh`, so the existing watcher polls it on its normal cadence and turns its one line into a `check:` wake; no separate schedule is involved.
The armed check runs whenever that home has a watcher running, and arming alone does not make watcher supervision required, so a home with no in-flight work and no other reason to watch does not start a watcher just for this check.
Registering the check is itself a reason to watch, so the home keeps a watcher for it after the last task is torn down, and `disarm` is what ends that need.
`bin/fm-tool-update-check.sh disarm` removes the shim, its trust binding, and the report record.
The check prints nothing when everything is current, and `state/.tool-updates` records the findings the last report was made from so the same pending update is reported once instead of on every poll.
A changed or returning condition is reported again.
Expand Down
2 changes: 1 addition & 1 deletion docs/supervision-protocols/grok.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ When you see a background-task-completed system reminder for the arm:
1. Run `bin/fm-wake-drain.sh` first.
2. Optionally fetch arm output with `get_command_or_subagent_output(<task_id>)` for the reason line.
3. Handle `signal`, `stale`, `check`, or `heartbeat` using the harness-neutral contract in `AGENTS.md`.
4. Ordinary wake: re-arm the next cycle with the same background `bin/fm-watch-arm.sh` call if work remains in flight or Relay still needs polling.
4. Ordinary wake: re-arm the next cycle with the same background `bin/fm-watch-arm.sh` call if the home still needs supervision, as `bin/fm-supervision-lib.sh` defines it.
5. Do not invent a wake from an attach-status line alone.
Drain the queue and act only on real wake records, the drain's `OPEN DECISIONS` and `UNREAD STATUS` entries, or a real watcher reason line.
Re-arm attaches to an existing healthy cycle when one is already present and follows its verified successor chain.
Expand Down
3 changes: 2 additions & 1 deletion docs/turnend-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Do not infer this guard's scope, loop safety, or compatibility tradeoffs for tho

`bin/fm-guard.sh` is a pull-based warning that runs only when another supervision command invokes it.
The turn-end guard closes the remaining gap at the primary's own turn boundary.
When work, a process-event source, or Relay polling needs supervision at that boundary and no identity-matched watcher has a fresh beacon, the harness integration must either block the turn end or force one bounded follow-up that uses the recovery instruction from the emitted session-start protocol.
When work, a process-event source, a registered custom check, or Relay polling needs supervision at that boundary and no identity-matched watcher has a fresh beacon, the harness integration must either block the turn end or force one bounded follow-up that uses the recovery instruction from the emitted session-start protocol.
The mid-turn pull warning uses the model-aware supervision verdict described below, while the turn-end guard keeps the PID-strict watcher predicate.
Away mode is the one place the turn-end guard accepts a different supervisor: while `state/.afk` exists the away-mode daemon owns supervision, so a live identity-matched daemon with a fresh beacon satisfies that boundary in place of a watcher process holding the lock.
The guard remains a backstop; [`watcher-continuity.md`](watcher-continuity.md) owns normal continuity.
Expand All @@ -31,6 +31,7 @@ For an in-scope primary, the guard counts in-flight work from `state/*.meta`.
Registered `state/procevent/*.source` records also require supervision even though they have no task metadata.
The default cross-harness mode exits silently with no supervision need.
Every mode treats `state/x-watch.check.sh` as supervision need, so Relay polling remains guarded without an in-flight task.
A custom check registered with `bin/fm-check-register.sh` counts the same way, so an operator's home-level poll keeps running after the last task is torn down.
Otherwise it calls `fm_watcher_healthy <state-dir> <watch-path> [grace-seconds] [home]` from `bin/fm-wake-lib.sh`, the same PID-strict identity-matched lock and fresh-beacon check used by `bin/fm-watch-arm.sh`: a stale beacon blocks even when a watcher pid is live, and a fresh leftover beacon blocks when the lock is missing, dead, or identity-mismatched.
The turn-end guard needs that strict check because it fires at the turn boundary, where the auto-arm is bringing a fresh watcher up for the upcoming idle period, and it cooperates with that arm rather than trusting a beacon left by the cycle that just ended.
`bin/fm-guard.sh`, the pull warning, instead uses the model-aware `fm_watcher_supervision_verdict` from the same library, because it fires mid-turn when the auto-arm model runs no watcher at all.
Expand Down
15 changes: 15 additions & 0 deletions tests/fm-claude-stop-autoarm.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,20 @@ test_arms_for_x_mode_poll_need_without_inflight() {
pass "auto-arm: X-mode poll need arms the cycle even with no tasks in flight"
}

test_arms_for_registered_custom_check_without_inflight() {
local dir out status
dir=$(make_primary_dir "$TMP_ROOT/check-need")
printf '#!/usr/bin/env bash\nexit 0\n' > "$dir/state/issue-comments.check.sh"
chmod 700 "$dir/state/issue-comments.check.sh"
FM_STATE_OVERRIDE="$dir/state" "$ROOT/bin/fm-check-register.sh" issue-comments >/dev/null \
|| fail "fm-check-register.sh could not register the custom check"
write_arm_fixture "$dir" actionable
out=$(run_autoarm "$dir" 2>/dev/null); status=$?
expect_code 2 "$status" "a registered custom check must keep the auto-arm active with zero tasks in flight"
[ -e "$dir/state/arm-ran" ] || fail "hook did not arm for the registered custom check"
pass "auto-arm: a registered custom check arms the cycle even with no tasks in flight"
}

test_single_flight_admits_exactly_one_owner() {
local dir rc1 rc2 count
dir=$(make_primary_dir "$TMP_ROOT/single-flight")
Expand Down Expand Up @@ -1168,6 +1182,7 @@ test_benign_cycle_end_with_live_watcher_is_silent
test_positive_recovery_budget_contention_preserves_episode
test_owner_mutex_contention_preserves_failure_episode_reset
test_arms_for_x_mode_poll_need_without_inflight
test_arms_for_registered_custom_check_without_inflight
test_single_flight_admits_exactly_one_owner
test_abandoned_owner_claim_is_reclaimed_and_rearms
test_arming_claim_with_fresh_beacon_is_never_reclaimed
Expand Down
Loading
Loading