Skip to content
Open
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
27 changes: 21 additions & 6 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,22 @@ run_arm() { # <output file, or empty for none>
# successor receives the closed arm's pid as FM_WATCH_PREDECESSOR_ARM_PID; it
# must outlive this hook's exit, so it is detached three ways: nohup, stdio
# away from the hook's pipes, and its own process group. Its one status line
# is awaited within the arm's own confirmation budget plus slack. Sets
# SUCCESSOR_FAILURE to the banner line for an unconfirmed successor.
# is awaited until the successor exits or the larger of GRACE and the arm's
# full first window (FM_ARM_CONFIRM_TIMEOUT plus one) elapses, plus startup
# slack: the arm never shortens its first window, and keeps extending further
# windows for a watcher still making progress up to the FM_GUARD_GRACE passed
# to it. Sets SUCCESSOR_FAILURE to the banner line for an unconfirmed successor.
SUCCESSOR_FAILURE=
start_handling_successor() { # <closed-arm-pid>
local out pid deadline budget line monitor_was_on=0
budget=${FM_ARM_CONFIRM_TIMEOUT:-30}
case "$budget" in ''|*[!0-9]*) budget=30 ;; esac
local out pid deadline budget window line monitor_was_on=0
budget=$GRACE
case "$budget" in ''|*[!0-9]*) budget=300 ;; esac
case "${OSTYPE:-}" in
msys*|mingw*|cygwin*) window=${FM_ARM_CONFIRM_TIMEOUT:-30} ;;
*) window=${FM_ARM_CONFIRM_TIMEOUT:-10} ;;
esac
case "$window" in ''|*[!0-9]*) window=30 ;; esac
[ "$(( window + 1 ))" -le "$budget" ] || budget=$(( window + 1 ))
Comment on lines +373 to +378

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Valid starts look failed

start_handling_successor reads FM_ARM_CONFIRM_TIMEOUT from the hook’s environment, but the real arm also reads config/watch.env. With FM_GUARD_GRACE=5 in the environment and FM_ARM_CONFIRM_TIMEOUT=30 only in watch.env, the hook waits about 16 seconds while the arm now allows its full 31-second first window. A successor that confirms after 20 seconds gets reported as unconfirmed even though its start is valid. The detached watcher can still finish starting, so this produces a false warning rather than stopping it.

Read the same effective timeout as the arm before calculating this wait, and cover file-based settings in the test.

if ! out=$(mktemp "$STATE/.claude-autoarm-successor.XXXXXX"); then
SUCCESSOR_FAILURE='The handling successor did not confirm a live watcher (its output file could not be created); this handling turn runs uncovered until the next turn end re-arms.'
return 1
Expand All @@ -377,13 +386,19 @@ start_handling_successor() { # <closed-arm-pid>
nohup "$SCRIPT_DIR/fm-watch-arm.sh" >"$out" 2>&1 </dev/null &
pid=$!
[ "$monitor_was_on" -eq 1 ] || set +m 2>/dev/null || true
deadline=$(( $(date +%s) + budget + 2 ))
deadline=$(( $(date +%s) + budget + 5 ))
while :; do
if grep -Eq '^watcher: (started|attached) pid=[0-9]+' "$out" 2>/dev/null; then
rm -f "$out" 2>/dev/null || true
return 0
fi
grep -q '^watcher: FAILED' "$out" 2>/dev/null && break
if ! kill -0 "$pid" 2>/dev/null; then
# An arm that confirmed and then exited still leaves its started line.
grep -Eq '^watcher: (started|attached) pid=[0-9]+' "$out" 2>/dev/null \
&& { rm -f "$out" 2>/dev/null || true; return 0; }
break
fi
[ "$(date +%s)" -ge "$deadline" ] && break
sleep 0.2
done
Expand Down
53 changes: 48 additions & 5 deletions bin/fm-watch-arm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ GRACE=${FM_GUARD_GRACE:-300}
# How long to wait for a freshly forked watcher to acquire the lock and beat.
# Git Bash/MSYS pays a much higher fork cost while the watcher completes its
# required pre-lock migration, so its bounded default covers that cold start.
# A child still consuming CPU when a window ends is slow, not wedged, so it is
# granted further windows up to GRACE after its start (owned_child_progressed).
case "${OSTYPE:-}" in
msys*|mingw*|cygwin*) ARM_CONFIRM_DEFAULT=30 ;;
*) ARM_CONFIRM_DEFAULT=10 ;;
Expand Down Expand Up @@ -367,7 +369,8 @@ fail_arm_ground_gone() {
return 1
}

# Give a successor the same bounded confirmation window used for a fresh child.
# Give a successor one window as long as a fresh child's first confirmation
# window, with no progress extensions.
# Adapter-owned continuations normally win immediately, but the bound avoids a
# false failure when process-close delivery and lock publication cross briefly.
wait_for_healthy_successor() {
Expand Down Expand Up @@ -704,7 +707,7 @@ handle_arm_signal() {
# publishing the home-bound lock identity. Do not TERM it in the middle of
# stale-lock acquisition: that can abandon the steal mutex. Let startup
# reach that cleanup-ready point (or exit naturally) before forwarding TERM,
# but never past the startup confirmation deadline.
# but never past the first confirmation window's deadline.
while fm_pid_alive "$child"; do
if fm_watcher_lock_matches_pid "$STATE" "$WATCH" "$child" "$FM_HOME" \
|| [ "$(date +%s)" -ge "$deadline" ]; then
Expand All @@ -729,8 +732,13 @@ child_out=$(mktemp "$STATE/.watch-arm-output.XXXXXX") || {
exit 1
}
# date(1) exposes whole seconds. Keep the configured confirmation budget from
# collapsing when startup begins just before the next second boundary.
deadline=$(( $(date +%s) + CONFIRM_TIMEOUT + 1 ))
# collapsing when startup begins just before the next second boundary. The
# first window is always the full budget; GRACE caps only the further windows
# owned_child_progressed grants, so a shorter grace grants no extension.
confirm_start=$(date +%s)
deadline=$(( confirm_start + CONFIRM_TIMEOUT + 1 ))
window_end=$deadline
confirm_limit=$(( confirm_start + GRACE ))
if [ -n "${FM_WATCH_PREDECESSOR_ARM_PID:-}" ]; then
FM_WATCH_HANDLING_SUCCESSOR=1 "$WATCH" >"$child_out" &
else
Expand All @@ -740,6 +748,39 @@ child=$!
cycle_begin "$child" started "$(fm_pid_identity "$child" 2>/dev/null || true)"
child_done=0

# Print the CPU time the child has consumed, as an opaque value that changes
# only while it runs: utime+stime ticks from a Linux-compatible /proc (Linux,
# Git Bash/MSYS), else ps(1)'s cumulative time. Empty when neither is readable.
child_cpu_time() {
local stat_line
local -a stat_fields
if [ -r "/proc/$child/stat" ]; then
stat_line=$(cat "/proc/$child/stat" 2>/dev/null) || return 0
# After the final comm delimiter, indexes 11 and 12 are stat fields 14 and 15.
read -r -a stat_fields <<< "${stat_line##*)}"
printf '%s+%s\n' "${stat_fields[11]:-}" "${stat_fields[12]:-}"
else
ps -o time= -p "$child" 2>/dev/null
fi
}
child_cpu=$(child_cpu_time)

# At the end of a confirmation window, true when the child consumed CPU during
# it and GRACE has not elapsed since its start; it then opens the next window.
# A window with no CPU progress, or the grace running out, ends the wait. Only
# the first window bounds a signal's wait (handle_arm_signal), so an owner that
# stops this arm past it still sees it exit promptly.
owned_child_progressed() {
local now cpu
now=$(date +%s)
[ "$now" -lt "$confirm_limit" ] || return 1
cpu=$(child_cpu_time)
[ -n "$cpu" ] && [ "$cpu" != "$child_cpu" ] || return 1
child_cpu=$cpu
window_end=$(( now + CONFIRM_TIMEOUT + 1 ))
[ "$window_end" -le "$confirm_limit" ] || window_end=$confirm_limit
}

owned_child_finished() {
local rc=$1 signal reason_type status
signal=$(cycle_signal_name "$rc")
Expand Down Expand Up @@ -850,7 +891,9 @@ while :; do
fail_arm_ground_gone
exit 1
fi
[ "$(date +%s)" -ge "$deadline" ] && break
if [ "$(date +%s)" -ge "$window_end" ]; then
owned_child_progressed || break
fi
sleep 0.2
done

Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3062,10 +3062,10 @@ FM_CLAUDE_AUTOARM_ATTEMPTS=2 # bounded Stop-owned arm attempts per Claude auto
FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard waits for watcher health, an open Stop auto-arm generation claim, or a fresh epoch before deciding recovery ownership or failure progression
FM_CLAUDE_AUTOARM_EPOCH_FRESH=15 # seconds a recorded auto-arm outcome remains eligible for the current event epoch's recovery or failure decision
FM_CLAUDE_TURNEND_BLOCK_BUDGET=3 # consecutive --claude guard re-blocks before the verified one-time attended fail-open; safely below Claude Code's 8-block override
FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED; default 30 on Git Bash/MSYS
FM_ARM_CONFIRM_TIMEOUT=10 # seconds of each window fm-watch-arm waits to confirm a fresh watcher; a watcher still consuming CPU when a window ends gets another, until FM_GUARD_GRACE after its start, before the arm reports FAILED (the first window is never shortened, so a grace shorter than it grants no extra window); default 30 on Git Bash/MSYS
FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm follows an attached watcher cycle (bin/fm-watch-arm.sh header)
FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure; default 35000 on Windows to stay above the MSYS confirm budget
FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached; default 35000 on Windows to stay above the MSYS confirm budget
FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached; default 35000 on Windows to stay above the MSYS confirm budget; the OpenCode and Pi primaries keep these fixed ready bounds rather than waiting out FM_ARM_CONFIRM_TIMEOUT's extended windows, so a slower start is still stopped and retried there (extending them is a possible follow-up)
FM_WATCH_ARM_RETIRE_TIMEOUT_MS=1000 # milliseconds Pi/OpenCode wait for an unready successor arm to exit before abandoning retries
FM_WATCH_REARM_RETRY_BASE_MS=250 # Pi/OpenCode adapter base delay for continuity restoration retries
FM_WATCH_REARM_RETRY_MAX_MS=4000 # Pi/OpenCode adapter cap for exponential continuity retry delay
Expand Down
5 changes: 3 additions & 2 deletions docs/watcher-continuity.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Claude's Stop hook also starts one handling successor before notification.
After an actionable foreground close, including an attached peer cycle that ended, the hook:

1. Launches `bin/fm-watch-arm.sh` with the closed arm's pid as `FM_WATCH_PREDECESSOR_ARM_PID`.
2. Waits for that arm's one status line.
2. Waits for that arm's one status line, until the arm exits or the larger of the hook's guard grace and the arm's full first confirmation window, plus a few seconds, elapses, because the arm never shortens its first window and extends further windows for a watcher still making progress up to that grace.
3. Only then exits 2 with the wake.

A child of the hook cannot outlive its exit-2 rewake.
Expand Down Expand Up @@ -468,6 +468,7 @@ They also prove that a legacy or handoff-phase watcher marker from an absent rep
- An external append beside a handling successor's inherited rows that surfaces both before and after the prior handling turn acknowledges its rows, including after a lost or malformed sequence counter and when the new row repeats an acknowledged inherited row byte for byte.
- 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.
- A slow watcher start still consuming CPU that keeps earning confirmation windows up to the grace, while a stalled start, a start past the grace, or a grace shorter than the first window ends with a `confirmation-timeout` failure.
- The self-healing moved-generation acknowledgement that consumes its handled rows and names its remedy.
- The already-acknowledged confirmation no-op for a matching generation, with its mismatched-generation, dead-pid, and lock-mismatch rejections preserved.
- The manual-restart generation churn that makes a confirmation for the churned generation report a mismatch, which an arm check without a reopen leaves in place.
Expand Down Expand Up @@ -513,7 +514,7 @@ It checks that a newly appended keyed decision is classified without rereading e
- One-notice failure episodes.
- Exit-2 translation.
- The handling successor an ended attached cycle starts with the closed arm as its predecessor and that outlives the rewake.
- An unconfirmed successor reported in the banner without withholding the wake.
- An unconfirmed successor reported in the banner without withholding the wake, a successor confirming past `FM_ARM_CONFIRM_TIMEOUT` or inside a first window longer than the grace still confirmed, and a silent successor bounded by the grace.
- Host-timeout HUP/TERM/INT translation into the same durable failure handoff.

It also covers generation-claim single-flight, stuck-claim supersession, superseded-owner silence, notice-marker refusal and retry, ownership-atomic episode reset, and the legacy upgrade shim.
Expand Down
55 changes: 54 additions & 1 deletion tests/fm-claude-stop-autoarm.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ write_arm_fixture() {
# successor (FM_WATCH_PREDECESSOR_ARM_PID set) is recorded apart in
# state/successor-ran so attempt counts stay about the foreground; it confirms
# a started watcher and exits, parks while state/successor-park exists, or
# fails while state/successor-fail exists.
# fails while state/successor-fail exists. state/successor-delay holds seconds
# to wait before reporting; state/successor-silent parks it without a report.
cat > "$dir/bin/fm-watch-arm.sh" <<'SH'
#!/usr/bin/env bash
if [ -n "${FM_WATCH_PREDECESSOR_ARM_PID:-}" ]; then
Expand All @@ -106,6 +107,8 @@ if [ -n "${FM_WATCH_PREDECESSOR_ARM_PID:-}" ]; then
printf 'watcher: FAILED - no live watcher with a fresh beacon\n'
exit 1
fi
while [ -e "$FM_HOME/state/successor-silent" ]; do sleep 0.05; done
[ ! -e "$FM_HOME/state/successor-delay" ] || sleep "$(cat "$FM_HOME/state/successor-delay")"
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
while [ -e "$FM_HOME/state/successor-park" ]; do sleep 0.05; done
exit 0
Expand Down Expand Up @@ -570,6 +573,53 @@ test_unconfirmed_handling_successor_still_rewakes() {
pass "auto-arm: an unconfirmed handling successor is reported in the rewake instead of blocking it"
}

# The arm keeps extending its confirmation windows for a watcher still making
# progress, up to FM_GUARD_GRACE, so the hook must not give up on a successor at
# FM_ARM_CONFIRM_TIMEOUT.
test_late_reporting_handling_successor_is_confirmed() {
local dir out status
dir=$(make_primary_dir "$TMP_ROOT/successor-late")
: > "$dir/state/task.meta"
write_arm_fixture "$dir" attached-delivered
printf '5\n' > "$dir/state/successor-delay"
out=$(FM_ARM_CONFIRM_TIMEOUT=1 FM_GUARD_GRACE=30 run_autoarm "$dir" 2>/dev/null); status=$?
expect_code 2 "$status" "a late-confirming handling successor must still rewake"
assert_contains "$out" "signal: task.status done: fixture peer cycle ended" "rewake must carry the delivered reason"
assert_not_contains "$out" "did not confirm" "a successor confirming past FM_ARM_CONFIRM_TIMEOUT but within grace is confirmed"
pass "auto-arm: a handling successor confirming past FM_ARM_CONFIRM_TIMEOUT is still confirmed"
}

# The arm never shortens its first confirmation window, so a grace below it
# must not cut the hook's successor wait short.
test_handling_successor_within_first_window_above_grace_is_confirmed() {
local dir out status
dir=$(make_primary_dir "$TMP_ROOT/successor-first-window")
: > "$dir/state/task.meta"
write_arm_fixture "$dir" attached-delivered
printf '7\n' > "$dir/state/successor-delay"
out=$(FM_ARM_CONFIRM_TIMEOUT=8 FM_GUARD_GRACE=1 run_autoarm "$dir" 2>/dev/null); status=$?
expect_code 2 "$status" "a handling successor confirming inside the first window must still rewake"
assert_contains "$out" "signal: task.status done: fixture peer cycle ended" "rewake must carry the delivered reason"
assert_not_contains "$out" "did not confirm" "a successor confirming inside the first window is confirmed even with a shorter grace"
pass "auto-arm: a handling successor confirming inside a first window longer than the grace is confirmed"
}

test_silent_handling_successor_is_bounded_by_grace() {
local dir out status start elapsed
dir=$(make_primary_dir "$TMP_ROOT/successor-silent")
: > "$dir/state/task.meta"
write_arm_fixture "$dir" attached-delivered
: > "$dir/state/successor-silent"
start=$(date +%s)
out=$(FM_ARM_CONFIRM_TIMEOUT=1 FM_GUARD_GRACE=2 run_autoarm "$dir" 2>/dev/null); status=$?
elapsed=$(( $(date +%s) - start ))
rm -f "$dir/state/successor-silent"
expect_code 2 "$status" "a silent handling successor must never withhold the delivered wake"
assert_contains "$out" "did not confirm a live watcher" "the rewake must say this turn runs uncovered"
[ "$elapsed" -lt 20 ] || fail "the successor wait must stay bounded by FM_GUARD_GRACE plus slack, took ${elapsed}s"
pass "auto-arm: a silent handling successor is bounded by the grace cap and reported"
}

test_failed_close_rewakes_with_failure_banner() {
local dir out status
dir=$(make_primary_dir "$TMP_ROOT/failed")
Expand Down Expand Up @@ -2008,6 +2058,9 @@ test_actionable_close_rewakes_with_reason
test_actionable_close_with_live_successor_rewakes_once
test_attached_cycle_end_starts_handling_successor
test_unconfirmed_handling_successor_still_rewakes
test_late_reporting_handling_successor_is_confirmed
test_handling_successor_within_first_window_above_grace_is_confirmed
test_silent_handling_successor_is_bounded_by_grace
test_failed_close_rewakes_with_failure_banner
test_failed_cycles_notify_once_and_keep_retrying
test_failure_notice_marker_write_refuses_delivery_and_retries
Expand Down
Loading