Skip to content
Merged
60 changes: 51 additions & 9 deletions bin/fm-supervision-host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@
# before the close is printed, so supervision continues when the session
# drops the handoff. It confirms no handling handoff, so the recovery
# marker still reads downtime and the re-arm owner delivers the close to
# main. The watcher singleton lock makes the session's next arm attach to
# that cycle instead of starting a second one;
# main. The host records that successor's arm before relinquishing it
# (detach_successor owns the persistence check and failure path). The
# session's next park without --restart requests a take-over of its cycle
# rather than an ordinary attach; bin/fm-watch-arm.sh's --take-over header owns the
# conditions under which that restores a single owner and the fallback;
# - away (an away record exists): every close goes to the engine.
# Every turn that starts attended meets that rule again at its start, so a
# close accepted away whose turn starts attended (the captain returned in
Expand Down Expand Up @@ -132,7 +135,12 @@
# left running (recorded with identities, never by name), including the
# engine descendants its turn recorded, removes that turn's files, and
# releases the branch actor's leases; it releases them again after every
# engine turn.
# engine turn. It also reads the record of a successor a pass-through left for
# main: while that arm still runs under its recorded identity, the first cycle
# without --restart requests a take-over rather than an ordinary attach.
# Activation removes the
# record only once that identity is no longer alive, so a later host retries a
# take-over that left it running.
#
# STATE (all under state/, owned here): .supervision-host (this host's pid and
# the processes it runs), .supervision-host-engine (the engine conversation:
Expand All @@ -141,7 +149,8 @@
# report scope and the reports it recorded), .supervision-host-prompt and
# .supervision-host-wake (the prompt and wake text of the current turn),
# .supervision-host-mirror (the dialog-mirror feed while an attended wake is
# rendered),
# rendered), .supervision-host-left (the pid and identity of the successor arm a
# pass-through left running for main, until that arm is gone),
# .supervision-host-health (the latch: errors, cooldown, and probe time, keyed
# to the main session, engine, and model), and .supervision-host.log (a bounded
# ledger of where every close went, with each engine turn's usage and
Expand Down Expand Up @@ -231,6 +240,7 @@ HOST_LOG="$STATE/.supervision-host.log"
ENGINE_PID_FILE="$STATE/.supervision-host.engine-pid"
HEALTH_FILE="$STATE/.supervision-host-health"
MIRROR_FEED="$STATE/.supervision-host-mirror"
LEFT_RECORD="$STATE/.supervision-host-left"

HOST_PID=$$
HOST_STARTED_SECONDS=$SECONDS
Expand All @@ -252,6 +262,9 @@ ENGINE_SUBSHELL=
SUCCESSOR_PID=
SUCCESSOR_OUT=
ENGINE_RUNNING=0
# The successor arm a predecessor's pass-through left for main, which the
# first cycle takes over.
LEFT_ARM=
# The running turn's result and diagnostics files, removed by the cleanup when
# the host is stopped mid-turn.
TURN_RESULT=
Expand Down Expand Up @@ -362,6 +375,18 @@ activate() {
done
rm -f "$STATE"/.supervision-host-arm.* "$STATE"/.supervision-host-descendants.* "$STATE"/.supervision-host-result.* \
"$STATE"/.supervision-host-errors.* "$STATE"/.supervision-host-readback.* "$TURN_FILE" "$MIRROR_FEED" 2>/dev/null || true
# The successor a pass-through left for main: the first cycle takes it over
# while it still answers to its recorded identity, and its record goes only
# once it does not.
if [ -f "$LEFT_RECORD" ]; then
pid='' identity=''
IFS="$(printf '\t')" read -r pid identity < "$LEFT_RECORD" || true
if fm_pid_alive "$pid" && [ -n "$identity" ] && [ "$(identity_of "$pid")" = "$identity" ]; then
LEFT_ARM=$pid
else
rm -f "$LEFT_RECORD"
fi
fi
printf 'host\t%s\t%s\n' "$HOST_PID" "$(identity_of "$HOST_PID")" > "$HOST_RECORD" || return 1
release_branch_leases
}
Expand Down Expand Up @@ -637,13 +662,27 @@ start_successor() { # <predecessor-arm-pid>
done
}

# Drop the successor from this host's cleanup without stopping it. The shell
# signals background jobs when it exits, and this arm's handler would then
# stop the watcher, so disown it first. The capture file stays tracked so the
# EXIT trap unlinks it; the arm already holds that descriptor and keeps
# waiting on the watcher.
# Record the successor for the next host to take over, then drop it from this
# host's cleanup without stopping it. A successor whose record does not read
# back as a regular file holding exactly its pid and identity stays tracked,
# so the cleanup stops it and main's next turn end arms a fresh cycle; that
# returns 1. The shell signals background jobs when it exits, and this arm's
# handler would then stop the watcher, so disown it first. The capture file
# stays tracked so the EXIT trap unlinks it; the arm already holds that
# descriptor and keeps waiting on the watcher.
detach_successor() {
local identity tmp=
[ -n "${SUCCESSOR_PID:-}" ] || return 0
identity=$(identity_of "$SUCCESSOR_PID")
if [ -z "$identity" ] || ! tmp=$(mktemp "$LEFT_RECORD.tmp.XXXXXX" 2>/dev/null) \
|| ! printf '%s\t%s\n' "$SUCCESSOR_PID" "$identity" > "$tmp" 2>/dev/null \
|| ! mv -f "$tmp" "$LEFT_RECORD" 2>/dev/null \
|| [ -L "$LEFT_RECORD" ] || [ ! -f "$LEFT_RECORD" ] \
|| [ "$(cat "$LEFT_RECORD" 2>/dev/null)" != "$SUCCESSOR_PID"$'\t'"$identity" ]; then
[ -z "$tmp" ] || rm -f "$tmp" "$LEFT_RECORD/${tmp##*/}" 2>/dev/null || true
log_line "pass-through successor-unrecorded $(printf '%s\n' "$REASON" | head -n 1)"
return 1
fi
disown "$SUCCESSOR_PID" 2>/dev/null || true
forget_process "$SUCCESSOR_PID"
SUCCESSOR_PID=
Expand Down Expand Up @@ -1008,6 +1047,9 @@ log_line "start gen=$GEN primary=$PRIMARY"
# The first cycle.
if [ "$FIRST_ARM_RESTART" -eq 1 ]; then
start_arm "$OWNER_PREDECESSOR" --restart
elif [ -n "$LEFT_ARM" ]; then
log_line "take-over arm=$LEFT_ARM"
start_arm "$OWNER_PREDECESSOR" --take-over "$LEFT_ARM"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
else
start_arm "$OWNER_PREDECESSOR"
fi || { echo "watcher: FAILED - the supervision host could not start a watcher cycle"; exit 1; }
Expand Down
59 changes: 59 additions & 0 deletions bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -971,9 +971,64 @@ _fm_recovery_marker_reopen_announced() {
fm_lock_release "$FM_WAKE_QUEUE_LOCK"
}

# The handover rule for a watcher stopped by bin/fm-watch-arm.sh --take-over
# (docs/watcher-continuity.md "Generation reuse" owns it). The snapshot reads
# the marker token and the queue's append sequence under both locks before the
# stop; handover-restore puts an acknowledged token back only while that
# sequence is unchanged and the marker reads the fresh pending downtime the
# stopped watcher's own close published.
FM_RECOVERY_HANDOVER_TOKEN=
FM_RECOVERY_HANDOVER_SEQ=
fm_recovery_marker_handover_snapshot() { # <marker>
local marker=$1 lock
FM_RECOVERY_HANDOVER_TOKEN=
FM_RECOVERY_HANDOVER_SEQ=
lock="${marker}.lock"
fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" || return 1
if ! fm_lock_acquire_wait "$lock"; then
fm_lock_release "$FM_WAKE_QUEUE_LOCK"
return 1
fi
if fm_recovery_marker_read "$marker"; then
# shellcheck disable=SC2034 # Read by callers after this function returns.
FM_RECOVERY_HANDOVER_TOKEN=$FM_RECOVERY_MARKER_TOKEN
fi
# shellcheck disable=SC2034 # Read by callers after this function returns.
FM_RECOVERY_HANDOVER_SEQ=$(cat "$STATE/.wake-queue.seq" 2>/dev/null || true)
fm_lock_release "$lock"
fm_lock_release "$FM_WAKE_QUEUE_LOCK"
}

_fm_recovery_marker_handover_restore() {
local marker=$1 token=$2 seq=$3 lock status=0
case "$token" in acked:*) ;; *) return 0 ;; esac
lock="${marker}.lock"
fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" || return 1
if ! fm_lock_acquire_wait "$lock"; then
fm_lock_release "$FM_WAKE_QUEUE_LOCK"
return 1
fi
if [ "$(cat "$STATE/.wake-queue.seq" 2>/dev/null || true)" = "$seq" ] \
&& fm_recovery_marker_read "$marker"; then
case "$FM_RECOVERY_MARKER_TOKEN" in
pending:downtime:*)
if [ "${FM_RECOVERY_MARKER_TOKEN##*:}" != "${token##*:}" ]; then
_fm_recovery_marker_restore_token_locked "$marker" "$token" || status=1
fi
;;
esac
fi
fm_lock_release "$lock"
fm_lock_release "$FM_WAKE_QUEUE_LOCK"
return "$status"
}

fm_recovery_transition() {
local marker=$1 action=$2 target=${3:-} value=${4:-} bound=${5:-}
case "$action" in
handover-restore)
_fm_recovery_marker_handover_restore "$marker" "$target" "$value"
;;
publish)
_fm_recovery_marker_publish "$marker" "${target:-downtime}" "$bound"
;;
Expand Down Expand Up @@ -1035,6 +1090,10 @@ fm_recovery_marker_reopen_announced() {
fm_recovery_transition "$1" reopen-announced
}

fm_recovery_marker_handover_restore() { # <marker> <snapshot-token> <snapshot-seq>
fm_recovery_transition "$1" handover-restore "$2" "$3"
}

# fm_lock_reap_dead_link <lockdir>
# Remove a link lock whose owner is dead without a nested mutex. Renaming the
# dead owner directory to this process's tombstone elects exactly one reaper,
Expand Down
122 changes: 104 additions & 18 deletions bin/fm-watch-arm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@
# bin/fm-watch.sh`: that pattern matches every firstmate home's watcher
# (secondmate homes run the same script) and would kill siblings.
#
# --take-over <arm-pid>: own the cycle that arm <arm-pid> owns, for an owner
# that left a successor cycle running through main's turn and now parks again
# (bin/fm-supervision-host.sh). Only when this home's healthy watcher is that
# arm's own child, it stops that watcher by its locked identity: a cycle that
# delivered a reason before the stop landed reports it exactly as an attached
# arm would, and otherwise this arm owns a fresh cycle as a plain arm does.
# Recovery restoration follows docs/watcher-continuity.md "Generation reuse";
# an unconfirmed stop leaves downtime for the fresh cycle's recovery check.
# Any other watcher, or one that outlives the stop,
# is attached to exactly as a plain arm attaches.
#
# --stop: the same home-scoped stop without re-arming, for an owner that ends
# its own supervision cycle on purpose (the supervision host's park boundary,
# bin/fm-supervision-host.sh). The stopped watcher publishes downtime exactly
Expand Down Expand Up @@ -137,9 +148,10 @@ ARM_PID=${BASHPID:-$$}
case "$CYCLE_LOG_MAX_BYTES" in ''|*[!0-9]*|0) CYCLE_LOG_MAX_BYTES=262144 ;; esac
case "$CYCLE_LOG_KEEP_LINES" in ''|*[!0-9]*|0) CYCLE_LOG_KEEP_LINES=1000 ;; esac

# The lifecycle ledger is diagnostic evidence, not a supervision dependency.
# Writes are bounded and best-effort so an observability failure cannot stall an
# otherwise healthy watcher cycle.
# Lifecycle writes are bounded and best-effort so an observability failure
# cannot stall an otherwise healthy watcher cycle. Take-over also uses the
# owner's row as stop evidence; missing evidence takes the safe recovery path
# (docs/watcher-continuity.md "Generation reuse").
cycle_clean_field() {
printf '%s' "$1" | tr '\t\r\n' ' ' | cut -c1-512
}
Expand Down Expand Up @@ -237,9 +249,10 @@ cycle_log_append() {
# A persistent adapter passes the arm pid that just closed. Once this new arm
# verifies its watcher, update that predecessor's final record in place so the
# one-record-per-cycle ledger captures the actual successor outcome without an
# extra synthetic lifecycle row.
# extra synthetic lifecycle row. A taking-over arm names itself instead, so its
# record of the cycle it took over names the cycle it started.
cycle_mark_predecessor_successor() {
local successor=$1 predecessor=${FM_WATCH_PREDECESSOR_ARM_PID:-} i tmp
local successor=$1 predecessor=${2:-${FM_WATCH_PREDECESSOR_ARM_PID:-}} i tmp
case "$predecessor" in
''|*[!0-9]*) return 0 ;;
esac
Expand Down Expand Up @@ -324,31 +337,35 @@ fail_unexplained_cycle() {
return 1
}

# Close a cycle whose reason line this arm could not read against the bounded
# terminal-delivery ledger the watcher publishes before releasing its lock.
close_unobserved_cycle() {
local i reason clean_identity record_pid record_identity record_reason
# Read the reason the current cycle's watcher recorded in the bounded
# terminal-delivery ledger it publishes before releasing its lock. Sets
# DELIVERED_REASON; fails when no record matches the cycle's pid and identity.
DELIVERED_REASON=
cycle_delivered_reason() {
local i clean_identity record_pid record_identity record_reason
DELIVERED_REASON=
clean_identity=$(printf '%s' "$cycle_watcher_identity" | tr '\t\r\n' ' ')
i=0
while ! fm_lock_try_acquire "$WATCH_DELIVERY_LOCK"; do
[ "$i" -lt 20 ] || {
fail_unexplained_cycle
return 1
}
[ "$i" -lt 20 ] || return 1
sleep 0.02
i=$((i + 1))
done
reason=
if [ -f "$WATCH_DELIVERY_LOG" ]; then
while IFS=$'\t' read -r record_pid record_identity record_reason; do
if [ "$record_pid" = "$cycle_watcher_pid" ] && [ "$record_identity" = "$clean_identity" ]; then
reason=$record_reason
DELIVERED_REASON=$record_reason
fi
done < "$WATCH_DELIVERY_LOG"
fi
fm_lock_release "$WATCH_DELIVERY_LOCK"
if [ -n "$reason" ]; then
printf '%s\n' "$reason"
[ -n "$DELIVERED_REASON" ]
}

# Close a cycle whose reason line this arm could not read against that ledger.
close_unobserved_cycle() {
if cycle_delivered_reason; then
printf '%s\n' "$DELIVERED_REASON"
return 0
fi
fail_unexplained_cycle
Expand Down Expand Up @@ -461,10 +478,17 @@ handling_successor_generation() {
mode=arm
handling_generation=
handling_watcher_pid=
take_over_arm_pid=
case "${1:-}" in
''|arm|--arm) mode=arm ;;
--restart) mode=restart ;;
--stop) mode=stop ;;
--take-over)
mode=take-over
take_over_arm_pid=${2:-}
case "$take_over_arm_pid" in ''|*[!0-9]*) echo "watcher: invalid take-over arm pid" >&2; exit 2 ;; esac
[ "$#" -eq 2 ] || { echo "watcher: unexpected take-over arguments" >&2; exit 2; }
;;
--handling-delivered)
mode=handling-delivered
handling_generation=${2:-}
Expand All @@ -474,7 +498,7 @@ case "${1:-}" in
case "$handling_watcher_pid" in ''|*[!0-9]*) echo "watcher: invalid successor watcher pid" >&2; exit 2 ;; esac
[ "$#" -eq 4 ] || { echo "watcher: unexpected handling delivery arguments" >&2; exit 2; }
;;
*) echo "usage: $(basename "$0") [--restart | --stop | --handling-delivered GENERATION --watcher-pid PID]" >&2; exit 2 ;;
*) echo "usage: $(basename "$0") [--restart | --stop | --take-over ARM_PID | --handling-delivered GENERATION --watcher-pid PID]" >&2; exit 2 ;;
esac

if [ "$mode" = handling-delivered ]; then
Expand Down Expand Up @@ -524,6 +548,67 @@ if [ "$mode" = stop ]; then
exit 0
fi

# Stop the watcher the named arm owns, by its locked identity, and wait for it
# to exit (header, --take-over). Returns 3 after printing the reason that cycle
# delivered before the stop landed, 0 once it stopped without delivering, and
# 1 when it was not stopped (its handover state was unreadable, or it outlived
# the stop), which leaves it to the plain attach below.
take_over_cycle() { # <watcher-pid> <identity>
local pid=$1 i owner_signal
cycle_begin "$pid" attached "$2"
fm_recovery_marker_handover_snapshot "$STATE/.watcher-down" || return 1
if attached_holder_live "$pid"; then
kill -TERM "$pid" 2>/dev/null || true
fi
i=0
while [ "$i" -lt 50 ] && fm_pid_alive "$pid"; do
sleep 0.1
i=$((i + 1))
done
if fm_pid_alive "$pid"; then
return 1
fi
if cycle_delivered_reason; then
cycle_log_append unknown unknown taken-over-delivered-wake none
printf '%s\n' "$DELIVERED_REASON"
return 3
fi
# Only the owner can wait on this watcher and distinguish our TERM from a
# self-exit that raced the stop. Give its post-wait ledger append a short bound.
i=0
owner_signal=
while [ "$i" -lt 50 ]; do
owner_signal=$(awk -F '\t' -v arm="$take_over_arm_pid" -v watcher="$pid" '
$1 == "arm_pid=" arm && $2 == "watcher_pid=" watcher { signal = $7 }
END { sub(/^signal=/, "", signal); print signal }
' "$CYCLE_LOG" 2>/dev/null || true)
[ -z "$owner_signal" ] || break
sleep 0.02
i=$((i + 1))
done
if [ "$owner_signal" = TERM ]; then
fm_recovery_marker_handover_restore "$STATE/.watcher-down" \
"$FM_RECOVERY_HANDOVER_TOKEN" "$FM_RECOVERY_HANDOVER_SEQ" || true
cycle_log_append unknown unknown taken-over none
else
cycle_log_append unknown unknown taken-over-unconfirmed-stop none
Comment thread
greptile-apps[bot] marked this conversation as resolved.
fi
return 0
}

TAKEN_OVER=0
if [ "$mode" = take-over ]; then
mode=arm
if healthy_watcher \
&& [ "$(ps -o ppid= -p "$HEALTHY_PID" 2>/dev/null | tr -d ' ')" = "$take_over_arm_pid" ]; then
take_over_cycle "$HEALTHY_PID" "$HEALTHY_IDENTITY"
case $? in
0) TAKEN_OVER=1 ;;
3) exit 0 ;;
esac
fi
fi

# If a genuinely live+fresh watcher already holds the lock, do not start a second
# one - attach to that cycle and wait until it ends so the harness notify fires
# then, not as an immediate empty wake. (--restart skips this: it just stopped
Expand Down Expand Up @@ -672,6 +757,7 @@ while :; do
exit 1
fi
cycle_mark_predecessor_successor "started:$child"
[ "$TAKEN_OVER" -eq 0 ] || cycle_mark_predecessor_successor "started:$child" "$ARM_PID"
if [ -n "$handling_generation" ]; then
echo "watcher: started pid=$child (beacon fresh) recovery-generation=$handling_generation"
else
Expand Down
Loading
Loading