From d671b2bf5dfd192efcc59c70f7c6d619f820134b Mon Sep 17 00:00:00 2001 From: Amplify Logic AI Date: Sun, 23 Aug 2026 09:53:12 +0200 Subject: [PATCH 1/5] fix(bin): scope the continuity gate's session-start allowance to the first run The watcher-continuity PreToolUse gate allowed bin/fm-session-start.sh unconditionally, so a mid-session re-run while tasks were in flight with no live watcher could re-acquire the home lock and run bootstrap's mutating sweeps - the deferred follow-up documented in docs/watcher-continuity.md. Generalize the shared ancestry walk into fm_session_lock_relation() (free / ancestry / foreign) in bin/fm-primary-scope-lib.sh, pass the relation into bin/fm-continuity-command-policy.mjs, and deny a session-start attempt whenever a live session holds the home lock: an ancestry holder means session start already ran here, a foreign holder means another session owns the home. A lock-free home - including a dead holder after a crash - keeps session start a recovery command, and the wake-drain, watcher-arm, literal teardown, and exact sentinel-enable allowances are unchanged. The deny guidance also stops naming the once-per-session entry point for a foreign-held lock. --- bin/fm-continuity-command-policy.mjs | 23 +++++++-- bin/fm-continuity-pretool-check.sh | 33 ++++++++---- bin/fm-primary-scope-lib.sh | 43 ++++++++++------ docs/arm-pretool-check.md | 4 +- docs/watcher-continuity.md | 28 ++++------- tests/fm-continuity-pretool-check.test.sh | 61 +++++++++++++++++++---- 6 files changed, 131 insertions(+), 61 deletions(-) diff --git a/bin/fm-continuity-command-policy.mjs b/bin/fm-continuity-command-policy.mjs index 460723cbea8..d4537060b1d 100755 --- a/bin/fm-continuity-command-policy.mjs +++ b/bin/fm-continuity-command-policy.mjs @@ -8,6 +8,14 @@ // re-enable) versus every other bin/fm-*.sh command. Unparseable or opaque // dynamic commands fail open so this gate can never become a blanket shell block. // +// The session-start allowance is scoped to a genuine first run by the caller's +// --session-lock relation (bin/fm-primary-scope-lib.sh fm_session_lock_relation): +// "free" keeps fm-session-start.sh a recovery command, while "ancestry" (this +// session already acquired the home lock, so session start already ran here) +// and "foreign" (another live session owns the home) both deny it with the +// midsession-session-start code. An absent or unrecognized relation is treated +// as "free" so a caller that predates the flag keeps today's behavior. +// // Classification is lexical: only a statically visible executed command word is // matched against RECOVERY_SCRIPTS. A bin/fm-bootstrap.sh command word therefore // stays denied wherever it executes, including when it is bundled after @@ -29,10 +37,12 @@ const RECOVERY_SCRIPTS = new Set([ ]); function parseArguments(argv) { - const result = { command: "", root: "" }; + const result = { command: "", root: "", "session-lock": "" }; for (let index = 0; index < argv.length; index += 1) { const name = argv[index]; - if (name !== "--command" && name !== "--root") throw new Error(`unknown argument: ${name}`); + if (name !== "--command" && name !== "--root" && name !== "--session-lock") { + throw new Error(`unknown argument: ${name}`); + } if (index + 1 >= argv.length) throw new Error(`${name} requires a value`); result[name.slice(2)] = argv[index + 1]; index += 1; @@ -145,21 +155,24 @@ function collectExecutedFleetScripts(command, root, depth = 0) { return scripts; } -export function classifyContinuityCommand(command, root) { +export function classifyContinuityCommand(command, root, sessionLock = "free") { + const lockHeld = sessionLock === "ancestry" || sessionLock === "foreign"; const scripts = collectExecutedFleetScripts(command, root); const blocked = scripts.find(({ name, unsafeTeardown, unsafeSentinel }) => - !RECOVERY_SCRIPTS.has(name) || unsafeTeardown || unsafeSentinel); + !RECOVERY_SCRIPTS.has(name) || unsafeTeardown || unsafeSentinel + || (name === "fm-session-start.sh" && lockHeld)); if (!blocked) return { decision: "allow", script: "" }; let code = "other-fleet"; if (blocked.unsafeTeardown) code = "unsafe-teardown"; else if (blocked.unsafeSentinel) code = "unsafe-sentinel"; + else if (blocked.name === "fm-session-start.sh") code = "midsession-session-start"; return { decision: "deny", script: blocked.name, code }; } function main() { const args = parseArguments(process.argv.slice(2)); if (!args.command || !args.root) return; - const result = classifyContinuityCommand(args.command, args.root); + const result = classifyContinuityCommand(args.command, args.root, args["session-lock"]); if (result.decision === "deny") process.stdout.write(`deny\t${result.script}\t${result.code}\n`); } diff --git a/bin/fm-continuity-pretool-check.sh b/bin/fm-continuity-pretool-check.sh index f2fc9e5ae68..a6a3be57b9e 100755 --- a/bin/fm-continuity-pretool-check.sh +++ b/bin/fm-continuity-pretool-check.sh @@ -15,17 +15,21 @@ # bin/fm-session-start.sh runs inside its own process is allowed. That is not a # hole: session start takes the per-home session lock first, and holding that # lock is exactly what gates bootstrap's five mutating sweeps (see the ORDERING -# header in fm-session-start.sh). The accepted limitation of that boundary is -# owned by the "Known limitation" section of docs/watcher-continuity.md. +# header in fm-session-start.sh). The first-run scoping of that boundary is +# owned by the "Session-start first-run scoping" section of +# docs/watcher-continuity.md. # # The deny guidance keeps the two entry points distinct. bin/fm-wake-drain.sh is # the action that is always safe mid-session; the once-per-session -# bin/fm-session-start.sh (AGENTS.md section 3) is named only when this hook -# process's own ancestry does not already hold the home session lock, so a -# session that has already run it is never pointed back at an out-of-contract -# mid-session re-run. That ancestry check (fm_session_lock_in_ancestry, shared -# with bin/fm-sessionstart-nudge.sh) scopes guidance text only; the allow/deny -# decision itself is owned entirely by the classifier and is never affected. +# bin/fm-session-start.sh (AGENTS.md section 3) is both named and allowed only +# while no live session holds the home session lock. The session-lock relation +# (fm_session_lock_relation, shared with bin/fm-sessionstart-nudge.sh through +# fm_session_lock_in_ancestry) is passed to the classifier: a live holder in +# this hook's own ancestry means session start already ran in this session, a +# live foreign holder means another session owns the home, and either relation +# turns a session-start attempt into an ordinary gated fleet command denied +# with the midsession-session-start guidance below. Only the lock-free relation +# keeps session start a recovery command, exactly the genuine first run. # # The turn-end guard remains the final blocking backstop. This gate # closes the long-turn gap before another fleet mutation, but does not replace or @@ -115,7 +119,8 @@ fi command -v node >/dev/null 2>&1 || exit 0 [ -f "$POLICY" ] || exit 0 -CLASSIFICATION=$(node "$POLICY" --command "$COMMAND" --root "$FM_ROOT" 2>/dev/null) || exit 0 +LOCK_RELATION=$(fm_session_lock_relation "$STATE") || LOCK_RELATION=free +CLASSIFICATION=$(node "$POLICY" --command "$COMMAND" --root "$FM_ROOT" --session-lock "$LOCK_RELATION" 2>/dev/null) || exit 0 case "$CLASSIFICATION" in deny*) ;; *) exit 0 ;; @@ -134,9 +139,17 @@ case "$REASON_CODE" in unsafe-sentinel) REASON="[watcher-continuity] $FM_SUP_OUTAGE_SUMMARY During recovery only the literal bin/fm-supervision-sentinel.sh enable is allowed; arm, disarm, check, and every other host-sentinel invocation stays blocked until supervision is healthy (blocked: $BLOCKED_SCRIPT)" ;; + midsession-session-start) + if [ "$LOCK_RELATION" = ancestry ]; then + HOLDER_CLAUSE="This session's own ancestry already holds the home session lock, so the once-per-session bin/fm-session-start.sh has already run here and a mid-session re-run is not a recovery action." + else + HOLDER_CLAUSE="Another live session holds the home session lock, so the once-per-session bin/fm-session-start.sh belongs to that session and is not a recovery action here." + fi + REASON="[watcher-continuity] $FM_SUP_OUTAGE_SUMMARY No live watcher holds this home lock. $HOLDER_CLAUSE Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action; use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: $BLOCKED_SCRIPT)" + ;; *) SESSION_START_CLAUSE=" run the once-per-session bin/fm-session-start.sh instead only if you have not already run it earlier this session;" - fm_session_lock_in_ancestry "$STATE" && SESSION_START_CLAUSE="" + [ "$LOCK_RELATION" = free ] || SESSION_START_CLAUSE="" REASON="[watcher-continuity] $FM_SUP_OUTAGE_SUMMARY No live watcher holds this home lock. Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action;$SESSION_START_CLAUSE use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: $BLOCKED_SCRIPT)" ;; esac diff --git a/bin/fm-primary-scope-lib.sh b/bin/fm-primary-scope-lib.sh index 6a5092c31f6..985387fcc15 100755 --- a/bin/fm-primary-scope-lib.sh +++ b/bin/fm-primary-scope-lib.sh @@ -33,27 +33,38 @@ fm_primary_scope_matches() { [ -d "$state" ] || return 1 } -# Return 0 when the session lock in state dir $1 records a live PID inside this -# process's own ancestry, which means this harness session already acquired it -# and bin/fm-session-start.sh has already run here. -# One owner for that decision: the session-start nudge uses it to stay silent, -# and the continuity PreToolUse gate uses it to scope its recovery guidance. +# Print this process's relation to the session lock in state dir $1: +# free no live holder is recorded - the lock file is missing, unreadable, +# non-numeric, pid 1, or its holder is dead - so a session-start run +# here would be the genuine first acquisition. +# ancestry a live holder sits inside this process's own ancestry, which means +# this harness session already acquired the lock and +# bin/fm-session-start.sh has already run here. +# foreign a live holder exists outside this process's ancestry, or the +# ancestry walk could not prove ownership - another session owns the +# home, and any uncertainty lands here so an unproven lock is never +# treated as this session's own. +# One owner for that decision: the session-start nudge and the continuity +# PreToolUse gate both consume it rather than re-deriving lock ownership. # Walks at most eight parents, matching bin/fm-lock.sh and Pi's lockOwnership(). -# Any uncertainty - no lock, an unreadable or non-numeric holder, a dead holder, -# an unreadable parent - returns non-zero, so a caller never treats an unproven -# lock as owned by this session. -fm_session_lock_in_ancestry() { +fm_session_lock_relation() { local state=$1 lock_pid pid=$$ _ - [ -f "$state/.lock" ] || return 1 - IFS= read -r lock_pid < "$state/.lock" 2>/dev/null || return 1 + [ -f "$state/.lock" ] || { echo free; return 0; } + IFS= read -r lock_pid < "$state/.lock" 2>/dev/null || { echo free; return 0; } case "$lock_pid" in - ''|*[!0-9]*|1) return 1 ;; + ''|*[!0-9]*|1) echo free; return 0 ;; esac - kill -0 "$lock_pid" 2>/dev/null || return 1 + kill -0 "$lock_pid" 2>/dev/null || { echo free; return 0; } for _ in 1 2 3 4 5 6 7 8; do - [ "$pid" = "$lock_pid" ] && return 0 + [ "$pid" = "$lock_pid" ] && { echo ancestry; return 0; } pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 + [ -n "$pid" ] && [ "$pid" -gt 1 ] || { echo foreign; return 0; } done - return 1 + echo foreign +} + +# Return 0 only when fm_session_lock_relation resolves "ancestry" for state dir +# $1: this harness session provably already holds the home session lock. +fm_session_lock_in_ancestry() { + [ "$(fm_session_lock_relation "$1")" = ancestry ] } diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 7ce038c863f..6a1da54a098 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -23,8 +23,8 @@ It runs only in a primary home, and it denies only an executed `bin/fm-*.sh` com Ordinary shell commands, fleet-script names used as data, all commands in an idle fleet, child worktrees, session start, wake drain, watcher arm, ordinary literal teardown, and the explicit host-sentinel re-enable named by the session-start disarm banner remain allowed; every other host-sentinel invocation stays denied. Sentinel `disarm`, every other sentinel mode, extra arguments, and dynamic mode expressions stay denied because only enable improves recovery safety. The denial records the shared durable outage marker without any notifier work, then gives Claude reason-specific recovery guidance - drain with `bin/fm-wake-drain.sh` as the action that is always safe mid-session, use fail-closed `bin/fm-teardown.sh` for completed tasks, then re-arm via a tracked Claude background task - per the contract in [`watcher-continuity.md`](watcher-continuity.md). -That guidance names the once-per-session `bin/fm-session-start.sh` only in its pre-lock branch, chosen by the shared `fm_session_lock_in_ancestry()` predicate: a hook process whose own ancestry already holds the home session lock is not pointed back at a mid-session re-run. -The two branches differ in guidance text only; the allow/deny classification is identical either way and remains owned by `bin/fm-continuity-command-policy.mjs`. +That guidance names the once-per-session `bin/fm-session-start.sh` only while the home session lock has no live holder, chosen by the shared `fm_session_lock_relation()` predicate: a hook process whose own ancestry already holds the lock, or a home whose lock a live foreign session holds, is not pointed at a mid-session run. +The same relation is passed to `bin/fm-continuity-command-policy.mjs`, which remains the classification owner: a live-held lock turns a session-start attempt into a denied `midsession-session-start` classification, while a lock-free home keeps session start a recovery command, per the "Session-start first-run scoping" contract in [`watcher-continuity.md`](watcher-continuity.md). Only the executed command word is classified, so a direct `bin/fm-bootstrap.sh` remains denied while the `bin/fm-bootstrap.sh` that `bin/fm-session-start.sh` invokes inside its own process is allowed with its composing recovery script. `bin/fm-continuity-command-policy.mjs` reuses this document's shell lexer and command-position analysis but owns the recovery-versus-other-fleet classification. Malformed transport or opaque dynamic syntax fails open so this narrow gate cannot become a blanket Bash block. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 1a1a7a83486..1db7035fe46 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -39,23 +39,17 @@ The turn-end guard and its adapters remain the final in-harness backstop rather On an unhealthy result, both that guard and the Claude continuity gate call `bin/fm-supervision-sentinel.sh note-outage`, which records the shared durable outage marker with local writes alone. Neither hook forks a notifier, backgrounds notifier work, or waits on external-channel delivery: they must return their blocking result immediately, and the scheduled host check owns delivery. -## Known limitation - -The session-start allowance is not restricted to a genuinely first invocation, and this gate's allow/deny decision does not try to detect one. -`bin/fm-lock.sh` treats a recorded holder PID equal to the current harness PID as a successful re-acquire, so a mid-session re-run of `bin/fm-session-start.sh` — not only the fresh case where no lock exists yet — also passes this gate, acquires the lock, and runs bootstrap's five mutating sweeps. -The gate's own trigger condition is watcher liveness, which is independent of session-lock ownership, so the classification does not distinguish the two cases from where it sits. -The deny guidance does distinguish them with `fm_session_lock_in_ancestry()`, so a session that already holds the lock is no longer told about session start at all. -Applying that same ancestry test to the allow/deny classification is beyond this fix's risk budget: it is a deliberate scope deferral rather than a technical limitation, and remains a deferred follow-up. - -That is accepted rather than patched now, for two reasons. -The session lock, not watcher liveness, is the actual mutation authority for those sweeps. -And "run session-start exactly once per session" is a behavioral contract owned and enforced by AGENTS.md and agent discipline, not by a PreToolUse gate; a half-enforced first-invocation check here would be less trustworthy than this stated limitation. - -A re-run does exceed the documented contract, so the blast radius is stated plainly rather than minimized. -AGENTS.md section 3 scopes session start to once per session, and `bin/fm-bootstrap.sh`'s secondmate liveness sweep scopes itself to "session start (reboot/restart) only" with a mid-session secondmate death explicitly out of scope. -The practical consequence is that this gate transitively permits mutations it denies when invoked directly. -The most destructive of them is that sweep's own recovery action: for any secondmate whose liveness probe reads confidently dead, it runs `fm_backend_kill` and then respawns through `bin/fm-spawn.sh --secondmate` (`bin/fm-bootstrap.sh:447-448`). -It also includes the `bin/fm-visible-status.sh --all` Herdr presentation projection, whose cursor-worker `model_live=` meta write is owned by [`cursor-harness.md`](cursor-harness.md), and the secondmate reread nudges that bootstrap performs inside its own process. +## Session-start first-run scoping + +The session-start allowance is scoped to a genuinely first invocation, closing the previously documented mid-session re-run gap. +`bin/fm-lock.sh` treats a recorded holder PID equal to the current harness PID as a successful re-acquire, so before this scoping a mid-session re-run of `bin/fm-session-start.sh` also passed this gate, re-acquired the lock, and ran bootstrap's five mutating sweeps - including the secondmate liveness sweep's `fm_backend_kill` plus respawn recovery action, which `bin/fm-bootstrap.sh` scopes to "session start (reboot/restart) only". +The gate now passes the shared session-lock relation (`fm_session_lock_relation()` in `bin/fm-primary-scope-lib.sh`, the same ancestry walk `bin/fm-sessionstart-nudge.sh` consumes through `fm_session_lock_in_ancestry()`) into `bin/fm-continuity-command-policy.mjs`. +A lock-free home - no lock file, an unreadable or non-numeric holder, or a dead holder - keeps `bin/fm-session-start.sh` a recovery command, exactly the genuine first run including crash recovery over a stale lock. +A live holder inside the hook's own ancestry means this session already ran session start, and a live holder outside it means another session owns the home; either way the attempt is denied with the canonical outage summary, and the deny guidance for other fleet commands stops naming the once-per-session entry point. +The wake-drain, watcher-arm, ordinary literal teardown, and exact sentinel-enable allowances are independent of session-lock ownership and unchanged. + +The scoping is a gate over one harness's Bash tool calls, not the mutation authority itself: the session lock remains what actually gates bootstrap's mutating sweeps, and "run session-start exactly once per session" remains a behavioral contract owned by AGENTS.md section 3. +The relation inherits the ancestry walk's own bounds - at most eight parents, matching `bin/fm-lock.sh` - and any unproven ownership of a live lock resolves to the foreign relation, which denies rather than allows. ## Host-level outage sentinel diff --git a/tests/fm-continuity-pretool-check.test.sh b/tests/fm-continuity-pretool-check.test.sh index 8290855ac0e..1c4930dbcdf 100755 --- a/tests/fm-continuity-pretool-check.test.sh +++ b/tests/fm-continuity-pretool-check.test.sh @@ -104,20 +104,57 @@ test_gate_scope_and_recovery_exceptions() { } # Every deny above ran with no state/.lock at all, the genuine pre-lock case, so -# each asserted the guidance that names the once-per-session entry point. With -# this test's own pid recorded as the lock holder, the gate's ancestry walk -# resolves the holder inside the hook's own process ancestry and drops that -# clause instead of inviting an out-of-contract mid-session re-run. -test_lock_holding_session_gets_guidance_without_session_start() { - local held_reason +# each asserted the guidance that names the once-per-session entry point and the +# "session start recovery" allow above covered the genuine first run. With this +# test's own pid recorded as the lock holder, the gate's ancestry walk resolves +# the holder inside the hook's own process ancestry: the guidance drops that +# clause, and a session-start re-run itself is denied as a mid-session attempt. +test_lock_holding_session_rerun_refused() { + local held_reason rerun_reason printf '%s\n' "$$" > "$STATE/.lock" held_reason='[watcher-continuity] SUPERVISION OUTAGE: down for unknown duration (unknown since when; watcher beat file missing or unreadable); 1 task(s) in flight: task. No live watcher holds this home lock. Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action; use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-crew-state.sh)' expect_deny "lock-holding session guidance" 'bin/fm-crew-state.sh task' 'fm-crew-state.sh' "$held_reason" - # The ancestry walk scopes guidance text only: the classification is unchanged, - # so session start stays allowed exactly as it is without the lock. - expect_allow "session start while holding the lock" 'bin/fm-session-start.sh' + rerun_reason='[watcher-continuity] SUPERVISION OUTAGE: down for unknown duration (unknown since when; watcher beat file missing or unreadable); 1 task(s) in flight: task. No live watcher holds this home lock. This session'\''s own ancestry already holds the home session lock, so the once-per-session bin/fm-session-start.sh has already run here and a mid-session re-run is not a recovery action. Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action; use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-session-start.sh)' + expect_deny "mid-session session-start re-run" 'bin/fm-session-start.sh' 'fm-session-start.sh' "$rerun_reason" + expect_deny "nested mid-session session-start re-run" "bash -lc 'bin/fm-session-start.sh'" 'fm-session-start.sh' "$rerun_reason" + # The other recovery allowances are unaffected by session-lock ownership. + expect_allow "wake drain while holding the lock" 'bin/fm-wake-drain.sh' + expect_allow "watch arm while holding the lock" 'bin/fm-watch-arm.sh' + expect_allow "fail-closed teardown while holding the lock" 'bin/fm-teardown.sh task' + expect_allow "exact sentinel enable while holding the lock" 'bin/fm-supervision-sentinel.sh enable' rm -f "$STATE/.lock" - pass "continuity gate omits the session-start clause for a session that already holds the home lock" + pass "continuity gate refuses a mid-session session-start re-run and keeps every other allowance for the lock-holding session" +} + +# A live holder outside this hook's ancestry means another session owns the +# home: session start belongs to that session, so the attempt is refused and +# the guidance stops naming the once-per-session entry point. +test_foreign_lock_holder_session_start_refused() { + local holder foreign_reason foreign_guidance + sleep 300 & + holder=$! + printf '%s\n' "$holder" > "$STATE/.lock" + foreign_reason='[watcher-continuity] SUPERVISION OUTAGE: down for unknown duration (unknown since when; watcher beat file missing or unreadable); 1 task(s) in flight: task. No live watcher holds this home lock. Another live session holds the home session lock, so the once-per-session bin/fm-session-start.sh belongs to that session and is not a recovery action here. Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action; use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-session-start.sh)' + expect_deny "ancestry-mismatch session start" 'bin/fm-session-start.sh' 'fm-session-start.sh' "$foreign_reason" + foreign_guidance='[watcher-continuity] SUPERVISION OUTAGE: down for unknown duration (unknown since when; watcher beat file missing or unreadable); 1 task(s) in flight: task. No live watcher holds this home lock. Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action; use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-crew-state.sh)' + expect_deny "foreign-lock guidance drops the session-start clause" 'bin/fm-crew-state.sh task' 'fm-crew-state.sh' "$foreign_guidance" + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + rm -f "$STATE/.lock" + pass "continuity gate refuses session start when a live foreign session holds the home lock" +} + +# A recorded but dead holder is the crash-recovery case: the lock is not live, +# so the next session-start invocation is the genuine first run and stays +# allowed exactly like the no-lock case. +test_dead_lock_holder_first_run_allowed() { + local dead + dead=$(bash -c 'echo $$') + while kill -0 "$dead" 2>/dev/null; do sleep 0.1; done + printf '%s\n' "$dead" > "$STATE/.lock" + expect_allow "first session start over a dead holder" 'bin/fm-session-start.sh' + rm -f "$STATE/.lock" + pass "continuity gate allows the genuine first session start over a dead lock holder" } test_deny_quantifies_stale_outage_and_names_every_task() { @@ -189,7 +226,9 @@ test_claude_hook_registration_preserves_stop_backstop() { } test_gate_scope_and_recovery_exceptions -test_lock_holding_session_gets_guidance_without_session_start +test_lock_holding_session_rerun_refused +test_foreign_lock_holder_session_start_refused +test_dead_lock_holder_first_run_allowed test_deny_quantifies_stale_outage_and_names_every_task test_live_lock_with_stale_beacon_still_denies_fleet_command test_child_worktree_and_malformed_input_fail_open From 8e72073affd98d0fc6c674a7b88d89b0b8a0c933 Mon Sep 17 00:00:00 2001 From: Amplify Logic AI Date: Sun, 23 Aug 2026 10:00:44 +0200 Subject: [PATCH 2/5] no-mistakes(review): Unify harness-aware session lock liveness --- bin/fm-lock.sh | 22 ++++-------- bin/fm-primary-scope-lib.sh | 25 +++++++++++-- tests/fm-continuity-pretool-check.test.sh | 44 ++++++++++++++++++----- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index d13ca1457fc..9ad08d22116 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -17,21 +17,20 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" LOCK="$STATE/.lock" mkdir -p "$STATE" - -# Known harness command names; extend when a new adapter is verified. -HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$' +# shellcheck source=bin/fm-primary-scope-lib.sh +source "$SCRIPT_DIR/fm-primary-scope-lib.sh" harness_pid() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename -- "$comm")" | grep -qE "$HARNESS_RE"; then + if printf '%s' "$(basename -- "$comm")" | grep -qE "$FM_HARNESS_RE"; then echo "$pid"; return 0 fi # Bare interpreter (e.g. node): match the harness name in its script path. case "$comm" in - *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;; + *node*|*python*) printf '%s' "$args" | grep -qE "$FM_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 @@ -39,17 +38,10 @@ harness_pid() { return 1 } -holder_alive() { # true if $1 is a live process that looks like a harness - local pid=$1 comm - kill -0 "$pid" 2>/dev/null || return 1 - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - printf '%s' "$(basename -- "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE" -} - if [ "${1:-}" = "status" ]; then if [ ! -f "$LOCK" ]; then echo "lock: free"; exit 0; fi old=$(cat "$LOCK") - if holder_alive "$old"; then echo "lock: held by live harness pid $old"; else echo "lock: stale (pid $old dead or not a harness)"; fi + if fm_harness_holder_alive "$old"; then echo "lock: held by live harness pid $old"; else echo "lock: stale (pid $old dead or not a harness)"; fi exit 0 fi @@ -59,7 +51,7 @@ if [ "${1:-}" = "release-stale" ]; then exit 0 fi old=$(cat "$LOCK") - if holder_alive "$old"; then + if fm_harness_holder_alive "$old"; then echo "error: refusing to release a live firstmate session lock (pid $old)" >&2 exit 1 fi @@ -76,7 +68,7 @@ fi me=$(harness_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } if [ -f "$LOCK" ]; then old=$(cat "$LOCK") - if [ "$old" != "$me" ] && holder_alive "$old"; then + if [ "$old" != "$me" ] && fm_harness_holder_alive "$old"; then echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 exit 1 fi diff --git a/bin/fm-primary-scope-lib.sh b/bin/fm-primary-scope-lib.sh index 985387fcc15..a72c8b9aae3 100755 --- a/bin/fm-primary-scope-lib.sh +++ b/bin/fm-primary-scope-lib.sh @@ -4,6 +4,25 @@ # whether this hook's own harness session already acquired that home's lock. # This file is sourced by hook entrypoints and has no side effects on source. +# Known harness command names; extend when a new adapter is verified. +FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$' + +fm_harness_holder_alive() { + local pid=$1 comm args + kill -0 "$pid" 2>/dev/null || return 1 + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + if printf '%s' "$(basename -- "$comm")" | grep -qE "$FM_HARNESS_RE"; then + return 0 + fi + case "$comm" in + *node*|*python*) + args=$(ps -o args= -p "$pid" 2>/dev/null) + printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" + ;; + *) return 1 ;; + esac +} + # Return 0 when $1 carries a genuine secondmate-home marker. fm_root_is_secondmate_home() { local marker="$1/.fm-secondmate-home" id LC_ALL=C @@ -35,8 +54,8 @@ fm_primary_scope_matches() { # Print this process's relation to the session lock in state dir $1: # free no live holder is recorded - the lock file is missing, unreadable, -# non-numeric, pid 1, or its holder is dead - so a session-start run -# here would be the genuine first acquisition. +# non-numeric, pid 1, or its holder is dead or not a harness - so a +# session-start run here would be the genuine first acquisition. # ancestry a live holder sits inside this process's own ancestry, which means # this harness session already acquired the lock and # bin/fm-session-start.sh has already run here. @@ -54,7 +73,7 @@ fm_session_lock_relation() { case "$lock_pid" in ''|*[!0-9]*|1) echo free; return 0 ;; esac - kill -0 "$lock_pid" 2>/dev/null || { echo free; return 0; } + fm_harness_holder_alive "$lock_pid" || { echo free; return 0; } for _ in 1 2 3 4 5 6 7 8; do [ "$pid" = "$lock_pid" ] && { echo ancestry; return 0; } pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') diff --git a/tests/fm-continuity-pretool-check.test.sh b/tests/fm-continuity-pretool-check.test.sh index 1c4930dbcdf..8eaa8fe343b 100755 --- a/tests/fm-continuity-pretool-check.test.sh +++ b/tests/fm-continuity-pretool-check.test.sh @@ -28,9 +28,21 @@ run_command() { local command=$1 rc=0 : > "$OUT" : > "$ERR" - FM_ROOT_OVERRIDE="$PRIMARY" FM_HOME="$PRIMARY" FM_STATE_OVERRIDE="$STATE" \ - FM_SUPERVISION_SENTINEL_MODE=auto FM_WEDGE_ALARM_CHANNEL=osascript FM_WEDGE_ALARM_EXEC="$NOTIFY" \ - "$CHECK" --command "$command" > "$OUT" 2> "$ERR" || rc=$? + if [ "${RUN_FROM_HARNESS:-0}" = 1 ]; then + FM_ROOT_OVERRIDE="$PRIMARY" FM_HOME="$PRIMARY" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISION_SENTINEL_MODE=auto FM_WEDGE_ALARM_CHANNEL=osascript FM_WEDGE_ALARM_EXEC="$NOTIFY" \ + node -e ' + const fs = require("node:fs"); + const { spawnSync } = require("node:child_process"); + fs.writeFileSync(`${process.env.FM_STATE_OVERRIDE}/.lock`, `${process.pid}\n`); + const result = spawnSync(process.argv[1], ["--command", process.argv[2]], { stdio: "inherit" }); + process.exit(result.status ?? 1); + ' "$CHECK" "$command" codex > "$OUT" 2> "$ERR" || rc=$? + else + FM_ROOT_OVERRIDE="$PRIMARY" FM_HOME="$PRIMARY" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISION_SENTINEL_MODE=auto FM_WEDGE_ALARM_CHANNEL=osascript FM_WEDGE_ALARM_EXEC="$NOTIFY" \ + "$CHECK" --command "$command" > "$OUT" 2> "$ERR" || rc=$? + fi return "$rc" } @@ -106,12 +118,12 @@ test_gate_scope_and_recovery_exceptions() { # Every deny above ran with no state/.lock at all, the genuine pre-lock case, so # each asserted the guidance that names the once-per-session entry point and the # "session start recovery" allow above covered the genuine first run. With this -# test's own pid recorded as the lock holder, the gate's ancestry walk resolves -# the holder inside the hook's own process ancestry: the guidance drops that -# clause, and a session-start re-run itself is denied as a mid-session attempt. +# a harness-like Node parent recorded as the lock holder, the gate's ancestry +# walk resolves the holder inside the hook's own process ancestry: the guidance +# drops that clause, and session-start itself is denied as a mid-session attempt. test_lock_holding_session_rerun_refused() { local held_reason rerun_reason - printf '%s\n' "$$" > "$STATE/.lock" + RUN_FROM_HARNESS=1 held_reason='[watcher-continuity] SUPERVISION OUTAGE: down for unknown duration (unknown since when; watcher beat file missing or unreadable); 1 task(s) in flight: task. No live watcher holds this home lock. Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action; use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-crew-state.sh)' expect_deny "lock-holding session guidance" 'bin/fm-crew-state.sh task' 'fm-crew-state.sh' "$held_reason" rerun_reason='[watcher-continuity] SUPERVISION OUTAGE: down for unknown duration (unknown since when; watcher beat file missing or unreadable); 1 task(s) in flight: task. No live watcher holds this home lock. This session'\''s own ancestry already holds the home session lock, so the once-per-session bin/fm-session-start.sh has already run here and a mid-session re-run is not a recovery action. Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action; use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-session-start.sh)' @@ -122,6 +134,7 @@ test_lock_holding_session_rerun_refused() { expect_allow "watch arm while holding the lock" 'bin/fm-watch-arm.sh' expect_allow "fail-closed teardown while holding the lock" 'bin/fm-teardown.sh task' expect_allow "exact sentinel enable while holding the lock" 'bin/fm-supervision-sentinel.sh enable' + unset RUN_FROM_HARNESS rm -f "$STATE/.lock" pass "continuity gate refuses a mid-session session-start re-run and keeps every other allowance for the lock-holding session" } @@ -131,7 +144,7 @@ test_lock_holding_session_rerun_refused() { # the guidance stops naming the once-per-session entry point. test_foreign_lock_holder_session_start_refused() { local holder foreign_reason foreign_guidance - sleep 300 & + node -e 'setTimeout(() => {}, 300000)' codex & holder=$! printf '%s\n' "$holder" > "$STATE/.lock" foreign_reason='[watcher-continuity] SUPERVISION OUTAGE: down for unknown duration (unknown since when; watcher beat file missing or unreadable); 1 task(s) in flight: task. No live watcher holds this home lock. Another live session holds the home session lock, so the once-per-session bin/fm-session-start.sh belongs to that session and is not a recovery action here. Drain wakes with bin/fm-wake-drain.sh, the safe mid-session action; use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-session-start.sh)' @@ -144,6 +157,20 @@ test_foreign_lock_holder_session_start_refused() { pass "continuity gate refuses session start when a live foreign session holds the home lock" } +# A live non-harness process can reuse a stale holder PID, but it does not own +# the session lock and must not block the genuine crash-recovery first run. +test_reused_non_harness_pid_first_run_allowed() { + local holder + sleep 300 & + holder=$! + printf '%s\n' "$holder" > "$STATE/.lock" + expect_allow "first session start over a reused non-harness pid" 'bin/fm-session-start.sh' + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + rm -f "$STATE/.lock" + pass "continuity gate treats a live non-harness holder pid as stale" +} + # A recorded but dead holder is the crash-recovery case: the lock is not live, # so the next session-start invocation is the genuine first run and stays # allowed exactly like the no-lock case. @@ -228,6 +255,7 @@ test_claude_hook_registration_preserves_stop_backstop() { test_gate_scope_and_recovery_exceptions test_lock_holding_session_rerun_refused test_foreign_lock_holder_session_start_refused +test_reused_non_harness_pid_first_run_allowed test_dead_lock_holder_first_run_allowed test_deny_quantifies_stale_outage_and_names_every_task test_live_lock_with_stale_beacon_still_denies_fleet_command From e31dd966848c27c6e18cef562e192973098d440a Mon Sep 17 00:00:00 2001 From: Amplify Logic AI Date: Sun, 23 Aug 2026 10:09:14 +0200 Subject: [PATCH 3/5] no-mistakes(document): Align continuity gate documentation with first-run scoping --- docs/architecture.md | 2 +- docs/arm-pretool-check.md | 4 ++-- docs/sessionstart-nudge.md | 2 +- docs/watcher-continuity.md | 13 ++++++------- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0ee722d53bd..8f0d5a380ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ On `attached` it stays live across identity-matched successors, and an unexplain On restart-only `healthy` it exits zero after a surviving peer stands the duplicate child down, preserving the restart ownership contract without attaching. The arm layer records one bounded lifecycle row per observed cycle in `state/.watch-cycle-exits.log`; `state/.watch-triage.log` remains exclusively the absorbed-wake debug log. Pi and OpenCode verify session-lock ownership and launch one singleton successor from their child-close handlers before delivering an actionable wake prompt, with bounded exponential retry for failed restoration. -Claude keeps its tracked background-task protocol and adds a narrow PreToolUse continuity gate that refuses only non-recovery fleet commands when tasks are in flight and no identity-matched live watcher holds the home lock; [watcher-continuity.md](watcher-continuity.md) owns which commands stay allowed during that recovery and the accepted limits of that allowance. +Claude keeps its tracked background-task protocol and adds a narrow PreToolUse continuity gate that refuses non-recovery fleet commands and session-start attempts under a live home session lock when tasks are in flight and no identity-matched live watcher holds the home lock; [watcher-continuity.md](watcher-continuity.md) owns the recovery allowances and session-start first-run scoping. The existing turn-end guard adapters remain the final backstop for all verified primary harness protocols. 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 tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 6a1da54a098..724b13d8e4c 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -19,8 +19,8 @@ This policy is not a post-arm liveness guarantee. Claude also registers `bin/fm-continuity-pretool-check.sh` for Bash PreToolUse events. This is a separate, tightly bounded recovery gate rather than another watcher-shape policy. -It runs only in a primary home, and it denies only an executed `bin/fm-*.sh` command other than `bin/fm-session-start.sh`, `bin/fm-wake-drain.sh`, `bin/fm-watch-arm.sh`, the ordinary literal `bin/fm-teardown.sh`, or the exact literal `bin/fm-supervision-sentinel.sh enable` when task metadata is in flight and no identity-matched live watcher with a fresh beacon holds that home's lock. -Ordinary shell commands, fleet-script names used as data, all commands in an idle fleet, child worktrees, session start, wake drain, watcher arm, ordinary literal teardown, and the explicit host-sentinel re-enable named by the session-start disarm banner remain allowed; every other host-sentinel invocation stays denied. +It runs only in a primary home, and it denies only an executed `bin/fm-*.sh` command other than a first `bin/fm-session-start.sh` while the home session lock has no live holder, `bin/fm-wake-drain.sh`, `bin/fm-watch-arm.sh`, the ordinary literal `bin/fm-teardown.sh`, or the exact literal `bin/fm-supervision-sentinel.sh enable` when task metadata is in flight and no identity-matched live watcher with a fresh beacon holds that home's lock. +Ordinary shell commands, fleet-script names used as data, all commands in an idle fleet, child worktrees, a first session start over a free or stale lock, wake drain, watcher arm, ordinary literal teardown, and the explicit host-sentinel re-enable named by the session-start disarm banner remain allowed; every other host-sentinel invocation and a session-start attempt while a live ancestral or foreign holder owns the home session lock stay denied. Sentinel `disarm`, every other sentinel mode, extra arguments, and dynamic mode expressions stay denied because only enable improves recovery safety. The denial records the shared durable outage marker without any notifier work, then gives Claude reason-specific recovery guidance - drain with `bin/fm-wake-drain.sh` as the action that is always safe mid-session, use fail-closed `bin/fm-teardown.sh` for completed tasks, then re-arm via a tracked Claude background task - per the contract in [`watcher-continuity.md`](watcher-continuity.md). That guidance names the once-per-session `bin/fm-session-start.sh` only while the home session lock has no live holder, chosen by the shared `fm_session_lock_relation()` predicate: a hook process whose own ancestry already holds the lock, or a home whose lock a live foreign session holds, is not pointed at a mid-session run. diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index 812ba2a328a..98cc5157321 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -13,7 +13,7 @@ The Shared Predicate section of `docs/turnend-guard.md` remains authoritative fo Before printing, the wrapper calls `fm_session_lock_in_ancestry()` from that shared lib, which reads `state/.lock` and walks at most eight parents from its own pid, matching `bin/fm-lock.sh` and Pi's `lockOwnership()` ancestry depth. If the lock names a live pid in that ancestry, session-start already ran in this harness session and the wrapper stays silent. -That predicate has one owner because a second consumer relies on the same answer: the Claude continuity PreToolUse gate uses it to decide whether its deny guidance may name `bin/fm-session-start.sh` at all ([`watcher-continuity.md`](watcher-continuity.md)). +The underlying lock relation has one owner because a second consumer relies on the same answer: the Claude continuity PreToolUse gate uses it to classify session-start attempts and scope its deny guidance ([`watcher-continuity.md`](watcher-continuity.md)). Every path exits 0, including malformed state and adapter errors, because Claude SessionStart exit 2 blocks session initialization. ## Harness transports diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 1db7035fe46..ab7751199fa 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -21,12 +21,11 @@ After the configured retry bound is exhausted, it delivers the original wake wit This is deliberate Option B ordering: the fleet is protected before the model handles the wake whenever restoration succeeds, but the model is never left blind when it does not. Claude retains its native tracked background-task completion path. -Its new PreToolUse continuity gate allows session start, wake drain, arm recovery, independently fail-closed teardown, and the literal `bin/fm-supervision-sentinel.sh enable` that the session-start disarm banner names, but refuses other fleet commands while tasks are in flight and no identity-matched live watcher with a fresh beacon holds the home lock. +Its new PreToolUse continuity gate allows a first session start while the home session lock has no live holder, wake drain, arm recovery, independently fail-closed teardown, and the literal `bin/fm-supervision-sentinel.sh enable` that the session-start disarm banner names, but refuses other fleet commands while tasks are in flight and no identity-matched live watcher with a fresh beacon holds the home lock. Every other host-sentinel invocation - `arm`, `disarm`, `check`, a bare call, extra arguments, or any dynamically built argument - stays denied in that state, so the one command the banner instructs the owner to run is reachable without widening the gate. -Allowing `bin/fm-session-start.sh` stops the gate self-blocking the one command AGENTS.md section 3 mandates as a session's first, since a fresh session has tasks in flight and no live watcher by definition. -The deny guidance keeps the two entry points distinct rather than pointing every denial at a once-per-session command: `bin/fm-wake-drain.sh` is the action that is always safe mid-session, and `bin/fm-session-start.sh` is named only when the hook process's own ancestry does not already hold the home session lock. -That is a two-branch guidance text, not two decisions: a session that already holds the lock is told to drain, tear down, and re-arm without being pointed back at an out-of-contract mid-session re-run, while a genuine pre-lock session still gets the session-start clause. -The ancestry test is `fm_session_lock_in_ancestry()` in `bin/fm-primary-scope-lib.sh`, one owner shared with `bin/fm-sessionstart-nudge.sh` so the nudge and this gate cannot drift on what "already ran session start" means. +Allowing `bin/fm-session-start.sh` only while the home session lock is free stops the gate self-blocking the one command AGENTS.md section 3 mandates as a session's first without admitting a mid-session re-run or a run owned by another live session. +The deny guidance keeps the two entry points distinct rather than pointing every denial at a once-per-session command: `bin/fm-wake-drain.sh` is the action that is always safe mid-session, and `bin/fm-session-start.sh` is named only while the session-lock relation is free. +The relation test is `fm_session_lock_relation()` in `bin/fm-primary-scope-lib.sh`, one owner shared with `bin/fm-sessionstart-nudge.sh` through `fm_session_lock_in_ancestry()` so the nudge and this gate cannot drift on lock ownership. The recovery set is keyed on the executed command word, so a direct `bin/fm-bootstrap.sh` stays denied while the `bin/fm-bootstrap.sh` that `bin/fm-session-start.sh` runs inside its own process is allowed with it; session start acquires the per-home session lock first, and holding that lock is what gates bootstrap's mutating sweeps. Allowing an ordinary literal teardown prevents a terminal wake from creating a recovery circle: forced or dynamically constructed teardown remains blocked, ordinary teardown itself still refuses dirty, unlanded, incomplete-scout, and unresolved-decision cases, and the turn-end guard continues to require supervision for any tasks left in flight. Codex retains its bounded foreground checkpoint protocol. @@ -131,7 +130,7 @@ Only the watcher process touches `state/.last-watcher-beat`; the sentinel reads `tests/fm-pi-watch-extension.test.sh` simulates actionable and empty child closes against the actual Pi and OpenCode close handlers, blocks prompt delivery to prove the successor launches first, verifies single-flight behavior, changes the session lock before close to prove ownership is rechecked, and hangs each successor arm to prove bounded fallback delivery includes the typed restoration failure. `tests/fm-watcher-lock.test.sh` covers verified-successor attach, the typed self-eviction failure, bounded and successor-linked lifecycle rows, startup reclamation of abandoned lock artifacts, live-versus-dead migration progress, the extended and post-migration confirmation windows, interrupted-sweep recovery, and a SIGSTOP counterfactual that distinguishes a live PID from a stale beacon before classifying termination. `tests/fm-continuity-pretool-check.test.sh` proves the Claude gate rejects only non-recovery fleet execution in the precise unhealthy state, treats a stale beacon as unhealthy even with a live identity-matched lock, admits only the literal host-sentinel `enable` while denying its other subcommands, and preserves the existing Stop registration. -It also asserts both guidance branches verbatim, and that a session holding the home lock still gets the identical allow/deny decisions, so the ancestry test can only ever change guidance text. +It also asserts both guidance branches verbatim, allows a genuine first run over a free or stale lock, refuses direct and nested session-start reruns for an ancestral holder, refuses an ancestry-mismatch run for a foreign holder, and preserves every other recovery allowance while a lock is held. `tests/fm-supervision-sentinel.test.sh` proves six-task stale-beacon detection with a live identity-matched lock, active-alert content, failed-delivery retry, exponential repeat backoff for one continuous outage, an immediate reset when the episode evidence changes, recovery re-arming, marker-only guard notes that never reach a notifier, `check` staying marker-only while still reporting a verdict and exiting non-zero on a detected outage, every mode honoring a durable disarm, unclaimed evidence refreshing on a moved episode without disturbing a live claim, symlinked-versus-foreign home identity, host-only liveness proof, one-per-home launchd registration, the watcher arm registering the host service exactly once and only after it has observed a healthy watcher, away mode deferring that registration to the daemon that can observe one, a contended arm retrying the lock once and then naming the missing service or check evidence instead of reporting success, manifest reconciliation, per-home backoff instead of churn when a retained service never completes a check, a registration-retry schedule independent of the repeat-alert tunables, a failed `enable` preserving the escalating failure record, a retry deadline beyond its own recorded window reading as stale evidence that suppresses nothing, a generated manifest never carrying a notifier override, explicit durable disarm/re-enable, a one-minute cadence, an unambiguous OS title, and the absence of every automatic recovery command. `tests/fm-session-start.test.sh` proves both the deliberate disarm and the suppressed-registration cooldown reach every session-start digest with their timing and recovery command. `tests/fm-turnend-guard.test.sh` additionally runs the Stop hook with the sentinel enabled and every channel pointed at a recorder, proving the block still renders fast, the marker lands unclaimed, and no channel fires. @@ -156,7 +155,7 @@ grok 0.2.103 (89c3d36fb6f1) [stable] Claude ran an arm fixture through its native tracked background option, observed background completion, allowed the wake drain, and refused the next unrelated fleet command before its body executed. That run's captured system message named `[watcher-continuity]`, `bin/fm-wake-drain.sh`, tracked Claude re-arm through `bin/fm-watch-arm.sh`, and the blocked `fm-crew-state.sh` command. The recovery guidance has since gained the conditional session-start clause described above, so the string this gate emits today for a pre-lock session is longer than the one that dated run recorded. -`tests/fm-claude-continuity-live-e2e.test.sh` asserts both current strings verbatim and is the place to read them: the credentialed turn covers the pre-lock branch, and a direct lab invocation with a recorded lock holder covers the lock-held branch. +`tests/fm-claude-continuity-live-e2e.test.sh` asserts both current generic-denial strings verbatim and is the place to read them: the credentialed turn covers the pre-lock branch, and a direct lab invocation with a recorded lock holder covers the lock-held branch. Refreshing this dated capture would take a fresh credentialed run of that opt-in test. Command: `FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-continuity-live-e2e.test.sh`. Observed result: `ok - Claude 2.1.214 (Claude Code) live E2E refused only the post-completion fleet command with exact re-arm guidance`. From 8009fefbc2ec1fa8352277c1961aaa1086adc0ff Mon Sep 17 00:00:00 2001 From: Amplify Logic AI Date: Sun, 23 Aug 2026 10:13:14 +0200 Subject: [PATCH 4/5] no-mistakes(lint): Silence deliberate ShellCheck single-quote warning --- tests/fm-continuity-pretool-check.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fm-continuity-pretool-check.test.sh b/tests/fm-continuity-pretool-check.test.sh index 8eaa8fe343b..8203d21cac0 100755 --- a/tests/fm-continuity-pretool-check.test.sh +++ b/tests/fm-continuity-pretool-check.test.sh @@ -29,6 +29,8 @@ run_command() { : > "$OUT" : > "$ERR" if [ "${RUN_FROM_HARNESS:-0}" = 1 ]; then + # The single quotes deliberately prevent shell expansion inside the JavaScript fixture. + # shellcheck disable=SC2016 FM_ROOT_OVERRIDE="$PRIMARY" FM_HOME="$PRIMARY" FM_STATE_OVERRIDE="$STATE" \ FM_SUPERVISION_SENTINEL_MODE=auto FM_WEDGE_ALARM_CHANNEL=osascript FM_WEDGE_ALARM_EXEC="$NOTIFY" \ node -e ' From d82ac3b361cacaed992aa4ee2895ff7e5dcec103 Mon Sep 17 00:00:00 2001 From: Amplify Logic AI Date: Sun, 23 Aug 2026 10:34:50 +0200 Subject: [PATCH 5/5] no-mistakes: apply CI fixes --- bin/fm-primary-scope-lib.sh | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/bin/fm-primary-scope-lib.sh b/bin/fm-primary-scope-lib.sh index a72c8b9aae3..58cdbde6656 100755 --- a/bin/fm-primary-scope-lib.sh +++ b/bin/fm-primary-scope-lib.sh @@ -11,16 +11,8 @@ fm_harness_holder_alive() { local pid=$1 comm args kill -0 "$pid" 2>/dev/null || return 1 comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - if printf '%s' "$(basename -- "$comm")" | grep -qE "$FM_HARNESS_RE"; then - return 0 - fi - case "$comm" in - *node*|*python*) - args=$(ps -o args= -p "$pid" 2>/dev/null) - printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" - ;; - *) return 1 ;; - esac + args=$(ps -o args= -p "$pid" 2>/dev/null) + printf '%s' "$(basename -- "$comm") $args" | grep -qE "$FM_HARNESS_RE" } # Return 0 when $1 carries a genuine secondmate-home marker. @@ -73,13 +65,17 @@ fm_session_lock_relation() { case "$lock_pid" in ''|*[!0-9]*|1) echo free; return 0 ;; esac - fm_harness_holder_alive "$lock_pid" || { echo free; return 0; } + kill -0 "$lock_pid" 2>/dev/null || { echo free; return 0; } for _ in 1 2 3 4 5 6 7 8; do [ "$pid" = "$lock_pid" ] && { echo ancestry; return 0; } pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || { echo foreign; return 0; } + [ -n "$pid" ] && [ "$pid" -gt 1 ] || break done - echo foreign + if fm_harness_holder_alive "$lock_pid"; then + echo foreign + else + echo free + fi } # Return 0 only when fm_session_lock_relation resolves "ancestry" for state dir