Skip to content
Merged
59 changes: 50 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 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,11 @@
# 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
# 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 +148,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 +239,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 +261,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 +374,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 +661,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 +1046,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.
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
115 changes: 100 additions & 15 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,
# after undoing the downtime the stop published when nothing was queued in
# between (docs/watcher-continuity.md "Generation reuse"), so the handover
# brings no recovery wake. 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 @@ -237,9 +248,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 +336,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 +477,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 +497,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 +547,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.
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 +756,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
2 changes: 1 addition & 1 deletion docs/supervision-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ The host asks the Pi branch's offer rule (`branchOfferForWake`, through `bin/fm-
So a close reaches main off Pi exactly when it would on Pi: a check trigger, a decision-owned signal or stale trigger, and a scan that is unsafe or holds nothing for the branch stay main's.
On that main-only pass-through the host starts the successor watcher cycle and leaves it running, then prints the close unchanged.
It leaves the watcher's recovery marker reading downtime, confirming no handling handoff, because the re-arm owner delivers a close to main only while that marker reads downtime.
The watcher's singleton lock makes the session's next arm attach to that cycle instead of starting a second one.
The session's next park requests a take-over to restore a single host-owned arm; the [host header](../bin/fm-supervision-host.sh) owns successor persistence and cleanup, and the [arm header](../bin/fm-watch-arm.sh) owns take-over eligibility and fallback.
It also passes the close through unchanged, with no added line, when any of these holds (`fm_supervision_host_attended_ready` in `bin/fm-supervision-engine-lib.sh` owns the list):

- The home names no usable engine.
Expand Down
2 changes: 2 additions & 0 deletions docs/watcher-continuity.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ A downtime republication of a pending episode reuses its generation.
A watcher close leaves an announced downtime episode announced, while a successful durable append opens a fresh pending generation so a live watcher can recover the new work.
An announced handling episode becomes pending downtime on the same generation because its handling turn may have been interrupted.
That handling republication gives a successor exactly one recovery presentation without orphaning the acknowledgement already printed for that generation.
A watcher stopped so an arm can take its cycle over (`bin/fm-watch-arm.sh --take-over`) publishes downtime like any close, but supervision never lapsed, so the taking arm restores an acknowledged episode that stop reopened only when the taken-over arm recorded that exact watcher ending by the take-over's TERM and no wake was appended in between; any other episode is left for the next cycle's arm check.

### What an acknowledgement retires

Expand Down Expand Up @@ -441,6 +442,7 @@ They also prove that a legacy or handoff-phase watcher marker from an absent rep
- A watcher close inside the handling window that must leave the printed acknowledgement valid.
- A re-arm whose recovery cycle is slowed after confirmation and must still surface rather than read as a watcher that stayed live.
- The self-healing moved-generation acknowledgement that consumes its handled rows and names its remedy.
- A take-over that owns a fresh cycle without a recovery wake, still surfaces queued work, and only attaches to a cycle the named arm does not own.
- The disposable-checkout arm refusal.
- The home-gone and state-gone watcher exits.
- The test reaper that stops a watcher armed for a temporary home.
Expand Down
Loading
Loading