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
75 changes: 11 additions & 64 deletions bin/fm-backlog-transition-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -319,78 +319,25 @@ fm_backlog_transition_applies() { # <config-dir> <data-dir> <kind>
# Run `tasks-axi` with an optional FM_TASKS_AXI_TIMEOUT bound. A caller that
# holds a lock across the call - the spawn commit and its preservation
# read-back run under the per-task meta lock - sets the bound, so an
# unresponsive tasks-axi cannot hold that lock open indefinitely; a timed-out
# call exits 124, or 137 when the kill-after had to fire (GNU timeout's own
# status for a KILL-forced expiry), and the callers treat either as the bound
# expiring and report the timeout as the reason through their existing error
# plumbing. GNU timeout is used where it exists,
# gtimeout where coreutils ships under that name, and a small perl watchdog
# elsewhere (a stock macOS host has perl but no timeout variant; perl is
# already a hard dependency of this library's byte validators, so the
# fallback adds no new tool). Every bounded path forces termination: a
# tasks-axi that ignores SIGTERM must not outlive the bound, since an
# unbounded call under the lock is exactly the hang the bound exists to
# prevent - so the GNU variants carry a kill-after of one further bound
# (TERM at the bound, KILL after that grace) and the watchdog kills the
# same way. When a bound was requested but no bounding mechanism exists at
# all, the call fails closed instead of running unbounded. Must be the last
# command of a subshell: the exec keeps the tasks-axi process exactly where
# the plain call sat, and the bound kills the child, not the caller.
# unresponsive tasks-axi cannot hold that lock open indefinitely. The bound is
# fm_exec_timed's (bin/fm-timeout-lib.sh), with one further bound of grace
# before KILL so a tasks-axi that ignores SIGTERM cannot outlive it either; the
# callers treat fm_timed_out statuses as the bound expiring and report the
# timeout as the reason through their existing error plumbing. A bound that
# cannot be enforced on this host fails closed instead of running unbounded.
# Must be the last command of a subshell: the exec keeps the tasks-axi process
# exactly where the plain call sat, and the bound kills the child, not the
# caller.
fm_tasks_axi_timeout_expired() { # <status>
case $1 in
124 | 137) return 0 ;;
esac
return 1
fm_timed_out "$1"
}

fm_tasks_axi() {
local bound=${FM_TASKS_AXI_TIMEOUT:-}
if [ -z "$bound" ]; then
exec tasks-axi "$@"
fi
if command -v timeout >/dev/null 2>&1; then
exec timeout -k "$bound" "$bound" tasks-axi "$@"
elif command -v gtimeout >/dev/null 2>&1; then
exec gtimeout -k "$bound" "$bound" tasks-axi "$@"
elif command -v perl >/dev/null 2>&1; then
# Fork, run tasks-axi in the child, and poll waitpid(WNOHANG) until the
# child exits or the bound expires: the same contract as
# `timeout $bound tasks-axi ...`. Expiry kills the child with TERM, waits
# one further bound of grace, then KILL, and exits 124 so the callers'
# timeout plumbing reports it. Polling rather than alarm+die keeps the
# bound off perl's platform-dependent syscall-restart signal semantics.
exec perl -MPOSIX=WNOHANG -e '
my $bound = shift;
exit 127 unless defined $bound && $bound =~ /\A[0-9]+\z/;
my $pid = fork;
exit 127 unless defined $pid;
if ($pid == 0) { exec @ARGV; exit 127 }
my $step = 0.05;
my $elapsed = 0;
while (1) {
my $done = waitpid $pid, WNOHANG;
exit(($? & 127) ? 128 + ($? & 127) : $? >> 8) if $done == $pid;
exit 127 if $done == -1;
if ($elapsed >= $bound) {
kill "TERM", $pid;
my $grace = 0;
my $gone = waitpid $pid, WNOHANG;
while ($gone == 0 && $grace < $bound) {
select undef, undef, undef, $step;
$grace += $step;
$gone = waitpid $pid, WNOHANG;
}
kill "KILL", $pid if $gone == 0;
waitpid $pid, 0;
exit 124;
}
select undef, undef, undef, $step;
$elapsed += $step;
}
' -- "$bound" tasks-axi "$@"
fi
printf 'fm_tasks_axi: cannot bound tasks-axi within %ss: none of timeout, gtimeout, or perl is available\n' "$bound" >&2
exit 127
fm_exec_timed "$bound" "$bound" tasks-axi "$@"
}

# Print one row's `tasks-axi show` output (plus stderr) from the addressing
Expand Down
11 changes: 9 additions & 2 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@
# this hook-owned process tree (never shell &); Claude owns the process
# group, so its timeout/session teardown kills arm and watcher together.
# HUP, TERM, and INT are translated through the ordinary durable failure
# handoff instead of leaving the generation frozen at arming.
# handoff instead of leaving the generation frozen at arming. Claude does
# not deliver the exit 2 of a hook it terminated at the configured timeout
# as a rewake (measured on Claude Code 2.1.278 and 2.1.281,
# docs/verification/supervision.md), so a park that outlives that timeout
# records the failure durably without waking an idle primary; nothing here
# shortens a quiet park, because no-change heartbeats are absorbed without
# closing the arm.
# - Translation: while supervision is still needed and AFK remains inactive,
# an actionable arm close (signal:/stale:/check:/heartbeat) prints one
# rewake banner to stderr and exits 2, which wakes Claude even while idle
Expand Down Expand Up @@ -218,7 +224,8 @@ autoarm_record() { # <outcome>
# watcher until its next wake, so that wait cannot be shortened without adding
# artificial turns. Translate a host interruption through the ordinary durable
# failure protocol instead: the winning generation records a terminal outcome,
# creates the episode marker, and exits 2 so Claude delivers a recovery turn.
# creates the episode marker, and exits 2 so Claude delivers a recovery turn -
# except after Claude's own timeout kill, whose exit 2 is dropped (header).
# A superseded generation remains silent, and an episode whose attended
# fail-open was already consumed must not restart automatic continuation.
# shellcheck disable=SC2329 # Invoked indirectly by the signal traps below.
Expand Down
34 changes: 32 additions & 2 deletions bin/fm-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@
# detect_own is the single owner of how the two combine; harness_marker and
# harness_ancestry only report evidence. Record each newly verified env marker
# in harness_marker, and each newly verified command name in harness_ancestry.
# Supervision-branch primary pin: a supervision branch running as its own
# process under another harness (a Pi engine under a Claude primary detects as
# pi) would otherwise resolve "own" - and with it an absent or "default"
# config/crew-harness or config/secondmate-harness - to its own harness and
# dispatch crew there. While FM_SUPERVISION_ACTOR=branch, a non-empty
# FM_SUPERVISION_PRIMARY_HARNESS names the primary's harness and replaces
# detection for the own, crew, and secondmate resolutions; a value that names
# no known harness refuses (exit 2, nothing on stdout) instead of resolving.
# Outside the branch actor the pin is ignored, and the evidence-only ancestry
# verbs never consult it.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand Down Expand Up @@ -381,6 +391,22 @@ harness_family() {
esac
}

# Print the supervision-branch primary pin when it applies (header), or
# nothing. Returns 2, with the reason on stderr, for a pin naming no harness.
supervision_primary_pin() {
local pin=${FM_SUPERVISION_PRIMARY_HARNESS:-}
[ "${FM_SUPERVISION_ACTOR:-}" = branch ] && [ -n "$pin" ] || return 0
case "$pin" in
claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse|rovo|omp|agy|devin)
printf '%s\n' "$pin"
;;
*)
echo "error: FM_SUPERVISION_PRIMARY_HARNESS='$pin' names no known harness; refusing to resolve the supervision branch's harness" >&2
return 2
;;
esac
}

# Combine the two evidence layers. The precedence boundary, in one rule: a
# marker names its harness, but only ancestry proves which harness owns this
# process tree, so a structural (comm) ancestor of a DIFFERENT harness wins.
Expand All @@ -395,8 +421,12 @@ harness_family() {
# - Different harness, interpreter-args ancestor only: the marker wins, because
# a harness-shaped path in some node process's arguments is weaker evidence
# than a harness publishing its own identity.
# The supervision-branch primary pin, when it applies, answers before either
# evidence layer is read.
detect_own() {
local marker ancestry strength harness
local marker ancestry strength harness pin
pin=$(supervision_primary_pin) || exit 2
[ -z "$pin" ] || { echo "$pin"; return; }
marker=$(harness_marker)
ancestry=$(harness_ancestry)
if [ -z "$ancestry" ]; then
Expand Down Expand Up @@ -463,7 +493,7 @@ secondmate_field() {
resolve_secondmate() {
local sm
sm=$(secondmate_field 1)
if [ -z "$sm" ] || [ "$sm" = "default" ]; then sm=$(resolve_crew); fi
if [ -z "$sm" ] || [ "$sm" = "default" ]; then sm=$(resolve_crew) || exit; fi
echo "$sm"
}

Expand Down
88 changes: 44 additions & 44 deletions bin/fm-lease-lib.sh
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
#!/usr/bin/env bash
# fm-lease-lib.sh - the per-task supervision lease contract (one owner).
#
# WHY. On the Pi supervision branch (docs/pi-supervision-branch.md), two LLM
# actors share one firstmate home inside one pi process: MAIN (the captain's
# chat) and BRANCH (the persistent supervision conversation). Most records have
# exactly one natural owner, but the overlap set - steering or stopping a
# worker, post-landing cleanup, backlog status for a task, stuck-worker
# recovery - could otherwise be mutated by both actors at once. The lease is
# the merge-conflict analog: a small per-task file saying which actor is
# changing that task right now, and the mutating entrypoints refuse the other
# actor while it exists.
# WHY. A supervision branch (docs/pi-supervision-branch.md) is a second LLM
# actor beside MAIN (the captain's chat) in one firstmate home - on Pi, a
# persistent conversation inside the same pi process - and nothing in this
# contract assumes the two actors share a process. Most records have exactly
# one natural owner, but the overlap set - steering or stopping a worker,
# post-landing cleanup, backlog status for a task, stuck-worker recovery -
# could otherwise be mutated by both actors at once. The lease is the
# merge-conflict analog: a small per-task file saying which actor is changing
# that task right now, and the mutating entrypoints refuse the other actor
# while it exists.
#
# CONTRACT.
# - Lease file: $STATE/.lease-<task>, one line "<actor>\t<pid>\t<epoch>".
Expand All @@ -18,19 +19,23 @@
# lease-command lock; leases never coordinate across firstmate homes.
# - Actors: exactly "main" and "branch". The current actor is
# $FM_SUPERVISION_ACTOR when set, else "main". The branch's shell gets
# FM_SUPERVISION_ACTOR=branch injected deterministically by the Pi branch
# extension's bash tool, not by agent memory. Any other value is refused
# loudly - an unknown actor is a wiring bug, not a third role.
# FM_SUPERVISION_ACTOR=branch injected deterministically by the process
# hosting it (on Pi, the branch extension's bash tool), not by agent
# memory. Any other value is refused loudly - an unknown actor is a wiring
# bug, not a third role.
# - Staleness: the recorded pid is the long-lived supervising process (the
# session-lock holder, or FM_LEASE_HOLDER_PID - see bin/fm-lease.sh), and
# both actors live inside that one pi process, so a dead recorded pid
# means the process died; the lease is cleared at the next claim, guard,
# or sweep. Liveness requires a Pi calling context plus state/.lock, and
# the recorded pid must BE its current holder, so a lease left by an exited
# Pi session goes stale even if its pid was recycled by an unrelated
# process, and a non-Pi home never honors a leftover Pi lease. A lease held by the
# live current session but an abandoned branch conversation is recovered
# by the branch extension's generation-activation cleanup.
# session-lock holder, or FM_LEASE_HOLDER_PID - see bin/fm-lease.sh), so a
# dead recorded pid means the supervising session died; the lease is
# cleared at the next claim, guard, or sweep. Liveness is the pure record
# test, identical in every calling context: the recorded pid is alive and
# IS the current state/.lock holder. So a lease left by an exited session
# goes stale for every reader, whichever harness now owns the home, and an
# unmarked main honors a live branch lease exactly as a Pi main does. The
# one residual is a recorded pid recycled onto the next session-lock holder
# itself; the host that owns a branch conversation releases that actor's
# leases when it activates a new one (the Pi branch extension's
# generation-activation cleanup), which also recovers a lease held by the
# live session but an abandoned branch conversation.
#
# THREAT MODEL (deliberate, captain-decided): these guards are
# CONFUSED-AGENT-GRADE, the same grade bin/fm-gate-refuse-lib.sh documents
Expand All @@ -45,11 +50,14 @@
# ACCIDENTAL override fails loudly inside the branch's own shell as well.
# - Guard semantics (fm_lease_guard): no lease, a same-actor lease, or a
# provably stale lease passes; a live lease held by the OTHER actor
# refuses with exit FM_LEASE_REFUSE_EXIT. In a Pi supervision context the
# guard retains the lease-command lock until fm_lease_guard_release, so the
# other actor cannot claim between the check and the guarded mutation. A
# home without the current Pi session lock cannot have a live lease, so
# the guard is a no-op there - non-Pi behavior is unchanged by construction.
# refuses with exit FM_LEASE_REFUSE_EXIT. Whenever the guard engages - a
# supervision context (Pi, or an explicit actor) or any lease file for the
# task - it retains the lease-command lock until fm_lease_guard_release,
# so the other actor cannot claim between the check and the guarded
# mutation. An unmarked caller with no lease file for the task returns
# before taking any lock, so a home that never ran a branch is unchanged
# byte for byte; that caller does not exclude a claim that starts during
# its mutation.
# - Role partition (fm_lease_forbid_branch): actions MAIN alone owns -
# merging a PR, landing local-only work, spawning workers, answering a
# decision - refuse the branch actor outright, lease or no lease, while
Expand Down Expand Up @@ -151,15 +159,11 @@ fm_lease_read() {
return 0
}

# fm_lease_live <task>: 0 iff a well-formed lease exists in a Pi context, its
# recorded pid is alive, and that pid IS the current session-lock holder (see
# the staleness contract above).
# fm_lease_live <task>: 0 iff a well-formed lease exists, its recorded pid is
# alive, and that pid IS the current session-lock holder (the staleness
# contract above). The calling context never enters the verdict.
fm_lease_live() {
local lock_pid
case "${PI_CODING_AGENT:-}:${FM_SUPERVISION_ACTOR:-}" in
true:*|*:main|*:branch) ;;
*) return 1 ;;
esac
fm_lease_read "$1" || return 1
[ -n "$FM_LEASE_ACTOR" ] || return 1
[ -n "$FM_LEASE_PID" ] || return 1
Expand All @@ -180,19 +184,18 @@ fm_lease_clear_stale() {
}

# fm_lease_guard <task> <action-label>: refuse (exit FM_LEASE_REFUSE_EXIT) when
# a live lease held by the OTHER actor exists for <task>. In a Pi supervision
# context, a successful guard retains the command lock across the caller's
# mutation; the caller must invoke fm_lease_guard_release from its EXIT cleanup.
# This closes the check/use race with a concurrent claim. Outside Pi, stale
# records are still cleaned but the lock is released before returning.
# a live lease held by the OTHER actor exists for <task>. Once engaged (the
# guard semantics above), a successful guard retains the command lock across
# the caller's mutation; the caller must invoke fm_lease_guard_release from its
# EXIT cleanup. This closes the check/use race with a concurrent claim.
fm_lease_guard() {
local task=$1 action=$2 actor lock lease_actor active=0
local task=$1 action=$2 actor lock lease_actor
fm_lease_valid_id "$task" || return 0
actor=$(fm_lease_actor) || exit "$FM_LEASE_REFUSE_EXIT"
case "${PI_CODING_AGENT:-}:${FM_SUPERVISION_ACTOR:-}" in
true:*|*:main|*:branch) active=1 ;;
true:*|*:main|*:branch) ;;
*) [ -e "$(fm_lease_path "$task")" ] || return 0 ;;
esac
[ "$active" = 1 ] || [ -e "$(fm_lease_path "$task")" ] || return 0
fm_lease_lock_helpers
lock="$STATE/.fm-lease-command.lock"
# A caller with more than one guarded phase already excludes claims until
Expand All @@ -203,9 +206,6 @@ fm_lease_guard() {
fi
if ! fm_lease_live "$task"; then
fm_lease_clear_stale "$task" || { fm_lease_guard_release; return 1; }
if [ "$active" != 1 ]; then
fm_lease_guard_release
fi
return 0
fi
lease_actor=$FM_LEASE_ACTOR
Expand Down
2 changes: 2 additions & 0 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ family_for_basename() {
fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\
fm-subagent-pretool-check.test.sh|\
fm-supervision-instructions.test.sh|fm-task-delivery.test.sh|\
fm-timeout-lib.test.sh|\
fm-tmux-submit-busy.test.sh|fm-trace-context-lib.test.sh|\
fm-transition-lib.test.sh|\
fm-test-run.test.sh|fm-test-isolation-proof.test.sh)
Expand Down Expand Up @@ -824,6 +825,7 @@ tests/fm-teardown.test.sh 145174
tests/fm-test-fixture-cleanup.test.sh 937
tests/fm-test-fixtures.test.sh 1562
tests/fm-test-isolation-proof.test.sh 2692
tests/fm-timeout-lib.test.sh 8541
tests/fm-tmux-agent-liveness.test.sh 1953
tests/fm-tool-update-check.test.sh 13832
tests/fm-trace-context-lib.test.sh 227
Expand Down
Loading
Loading