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
23 changes: 18 additions & 5 deletions bin/fm-continuity-command-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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`);
}

Expand Down
33 changes: 23 additions & 10 deletions bin/fm-continuity-pretool-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ;;
Expand All @@ -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
Expand Down
22 changes: 7 additions & 15 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,39 +17,31 @@ 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
done
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

Expand All @@ -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
Expand All @@ -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
Expand Down
58 changes: 42 additions & 16 deletions bin/fm-primary-scope-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
# 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
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.
fm_root_is_secondmate_home() {
local marker="$1/.fm-secondmate-home" id LC_ALL=C
Expand Down Expand Up @@ -33,27 +44,42 @@ 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 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.
# 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 ] || break
done
return 1
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
# $1: this harness session provably already holds the home session lock.
fm_session_lock_in_ancestry() {
[ "$(fm_session_lock_relation "$1")" = ancestry ]
}
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions docs/arm-pretool-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ 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 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.
Expand Down
Loading
Loading