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
14 changes: 11 additions & 3 deletions .omp/extensions/fm-primary-omp-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,17 @@ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(exte
const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250);
const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000);
const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5);
// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in
// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be
// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s.
// 35s on Windows does NOT bound the arm's confirmation: bin/fm-watch-arm.sh
// re-arms FM_ARM_CONFIRM_TIMEOUT at each newly observed startup phase, so this
// adapter can still retire an arm whose child is progressing normally. The value
// is inherited from the obsolete single-window derivation (it once sat above the
// arm's one 30s MSYS window) and is not a considered bound; closing the gap is
// tracked as separate follow-up work. The correct derivation is that the ready
// budget must exceed the arm's worst-case confirmation total, which is
// (FM_ARM_CONFIRM_TIMEOUT + 1 rounding second) x the number of re-armable startup
// phases (fork, lock, identity, beacon) - about 124s at the 30s Git Bash/MSYS
// default - rather than a single window. Conditioned on win32 so other platforms
// keep 12s.
const armReadyTimeoutMs = positiveInteger(
"FM_OMP_ARM_READY_TIMEOUT_MS",
process.platform === "win32" ? 35000 : 12000,
Expand Down
14 changes: 11 additions & 3 deletions .opencode/plugins/fm-primary-watch-arm.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@ import { resolve } from "node:path";
import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js";

const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm";
// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in
// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be
// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s.
// 35s on Windows does NOT bound the arm's confirmation: bin/fm-watch-arm.sh
// re-arms FM_ARM_CONFIRM_TIMEOUT at each newly observed startup phase, so this
// adapter can still retire an arm whose child is progressing normally. The value
// is inherited from the obsolete single-window derivation (it once sat above the
// arm's one 30s MSYS window) and is not a considered bound; closing the gap is
// tracked as separate follow-up work. The correct derivation is that the ready
// budget must exceed the arm's worst-case confirmation total, which is
// (FM_ARM_CONFIRM_TIMEOUT + 1 rounding second) x the number of re-armable startup
// phases (fork, lock, identity, beacon) - about 124s at the 30s Git Bash/MSYS
// default - rather than a single window. Conditioned on win32 so other platforms
// keep 12s.
const ARM_READY_TIMEOUT_DEFAULT_MS = process.platform === "win32" ? 35000 : 12000;
const ARM_READY_TIMEOUT_MS = positiveInteger("FM_OPENCODE_ARM_READY_TIMEOUT_MS", ARM_READY_TIMEOUT_DEFAULT_MS);
const ARM_RETIRE_TIMEOUT_MS = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000);
Expand Down
14 changes: 11 additions & 3 deletions .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,17 @@ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(exte
const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250);
const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000);
const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5);
// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in
// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be
// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s.
// 35s on Windows does NOT bound the arm's confirmation: bin/fm-watch-arm.sh
// re-arms FM_ARM_CONFIRM_TIMEOUT at each newly observed startup phase, so this
// adapter can still retire an arm whose child is progressing normally. The value
// is inherited from the obsolete single-window derivation (it once sat above the
// arm's one 30s MSYS window) and is not a considered bound; closing the gap is
// tracked as separate follow-up work. The correct derivation is that the ready
// budget must exceed the arm's worst-case confirmation total, which is
// (FM_ARM_CONFIRM_TIMEOUT + 1 rounding second) x the number of re-armable startup
// phases (fork, lock, identity, beacon) - about 124s at the 30s Git Bash/MSYS
// default - rather than a single window. Conditioned on win32 so other platforms
// keep 12s.
const armReadyTimeoutMs = positiveInteger(
"FM_PI_ARM_READY_TIMEOUT_MS",
process.platform === "win32" ? 35000 : 12000,
Expand Down
138 changes: 107 additions & 31 deletions bin/fm-watch-arm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@
# This script forks the watcher as a tracked child, then VERIFIES the outcome
# before it settles in. It confirms a watcher process is genuinely alive AND the
# liveness beacon (state/.last-watcher-beat) is fresh within FM_GUARD_GRACE (the
# single source of truth, shared with fm-watch.sh and fm-guard.sh), and prints
# exactly one unambiguous status line:
# single source of truth, shared with fm-watch.sh and fm-guard.sh). That
# confirmation is bounded per startup PHASE by FM_ARM_CONFIRM_TIMEOUT (the
# confirmation loop below owns the phases) rather than over the whole cold
# start, so a child that reaches each next startup step within one window has
# its budget re-armed and is left alone however slowly the whole start runs.
# The bound is still wall-clock: a child that spends a whole window inside one
# step is torn down and reported as a stall in that phase, even when it was
# otherwise progressing. It prints exactly one unambiguous verdict line:
# watcher: started pid=<N> (beacon fresh) - it launched one and confirmed it
# watcher: attached pid=<N> (beacon <age>s) - a live+fresh successor holds the lock;
# this arm attaches and follows it
Expand Down Expand Up @@ -69,9 +75,18 @@ WATCH_LOCK="$STATE/.watch.lock"
BEAT="$STATE/.last-watcher-beat"
# "Fresh" reuses the guard's threshold so there is one definition of liveness.
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.
# How long a freshly forked watcher may spend inside ONE startup phase (fork, no
# lock claimed yet; lock, the lock names this child; identity, its identity is
# published beside the lock; beacon, this child has touched the beacon - see the
# confirmation loop below) before this arm gives up on it. A timed-out cycle
# names the phase it stalled in, so all four appear in the ledger reason. The
# same value separately bounds wait_for_healthy_successor, which spends it as
# ONE whole window rather than per phase. The watcher's cold start is dominated
# by process creation: roughly 200 forks and execs before its first beat. Git
# Bash/MSYS pays ~70ms per process creation against well under 1ms on Linux
# (Windows itself, MSYS fork emulation, and per-process endpoint inspection each
# take a share), measured on 2026-09-17 as 5-8s to the lock and 12-18s to the
# first beat on an idle host, so its per-phase default is scaled up.
case "${OSTYPE:-}" in
msys*|mingw*|cygwin*) ARM_CONFIRM_DEFAULT=30 ;;
*) ARM_CONFIRM_DEFAULT=10 ;;
Expand Down Expand Up @@ -254,7 +269,9 @@ report_attached() {
echo "watcher: attached pid=$HEALTHY_PID (beacon ${age}s)"
}

# Give a successor the same bounded confirmation window used for a fresh child.
# Give a successor a single CONFIRM_TIMEOUT window, not the per-phase budget a
# fresh child gets: there are no startup phases to observe on an already
# published successor, so nothing here re-arms the deadline.
# 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 @@ -543,36 +560,94 @@ owned_child_finished() {
# Verify the outcome: poll until this child is the confirmed healthy watcher, or
# until some other watcher legitimately holds the singleton (a startup race), or
# until the child gives up. Only then print the honest line.
# 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 ))
#
# The bound is per startup PHASE, not per cold start. A fresh watcher publishes
# its liveness in three observable steps - the lock names the child, the child's
# identity is recorded beside it, the beacon is touched - and each newly
# observed step re-arms CONFIRM_TIMEOUT. A child that keeps reaching the next
# step is starting, however slowly the platform runs it; only a child that
# stalls for a whole window inside one step is torn down. One window over the
# whole start left an idle Git Bash/MSYS host about 10s of headroom (20s of a
# 31s window, measured 2026-09-17), which ordinary contention consumed, and the
# arm then killed a healthy child on its way to its first beat.
#
# The OpenCode, Pi and omp adapters' Windows ready budgets do not yet cover this
# per-phase total, so they can still retire an arm whose child is progressing;
# closing that is follow-up task fm-adapter-arm-timeout-w1, and those budgets
# must be DERIVED from (FM_ARM_CONFIRM_TIMEOUT + 1) x phase-count, not hardcoded.
#
# The beacon step asks whether the beacon is newer than this arm's own child
# output file, created immediately before the fork: a home that was supervised
# before still holds a beacon from the previous cycle, and mere existence would
# report the beacon step reached before this child ever published one.
#
# The step reads use shell builtins only: every fork this loop spends competes
# with the child for the same serialized process-creation path, and the earlier
# ten-fork iteration measurably slowed the child's own startup on MSYS. The full
# fm_watcher_healthy proof, which forks, is therefore gated on the cheap reads,
# and the gate is LOSSLESS - every state it skips is one the proof provably
# fails. It runs only when the lock names a live pid and either that pid is
# foreign (attach must be judged) or it is this child with pid-identity
# published and a beacon file present. An empty or dead lock pid fails
# fm_pid_alive; this child without a published identity fails the identity check
# in fm_watcher_lock_matches_pid; a missing beacon makes fm_path_age report
# 999999 so the age check fails. So the proof is skipped through the fork and
# lock steps always, and through the identity step too in a fresh home that
# holds no beacon file at all. A home whose beacon exists but has aged past
# GRACE still re-pays the proof on every identity-step poll until this child's
# first beat, because beacon age cannot be read without a fork; that cost now
# lands inside a window sized for that one step rather than inside a single
# budget for the whole cold start.
# $SECONDS is bash's forkless whole-second clock; the extra rounding second
# keeps a one-second budget from collapsing at a boundary, exactly as the
# date(1)-based deadline did before.
confirm_phase=fork
confirm_deadline=$((SECONDS + CONFIRM_TIMEOUT + 1))
while :; do
if healthy_watcher; then
if [ "$HEALTHY_PID" = "$child" ]; then
cycle_refresh_lock_before
if ! handling_generation=$(handling_successor_generation); then
cleanup_child
wait "$child" 2>/dev/null || true
cycle_log_append 1 none handling-handoff-failed none
echo "watcher: FAILED - established successor could not inspect handling state"
exit 1
fi
cycle_mark_predecessor_successor "started:$child"
if [ -n "$handling_generation" ]; then
echo "watcher: started pid=$child (beacon fresh) recovery-generation=$handling_generation"
else
echo "watcher: started pid=$child (beacon fresh)"
lock_pid=
{ IFS= read -r lock_pid < "$WATCH_LOCK/pid"; } 2>/dev/null || :
phase=fork
if [ "$lock_pid" = "$child" ]; then
phase=lock
if [ -s "$WATCH_LOCK/pid-identity" ]; then
phase=identity
[ "$BEAT" -nt "$child_out" ] && phase=beacon
fi
fi
if [ "$phase" != fork ] && [ "$phase" != "$confirm_phase" ]; then
confirm_phase=$phase
confirm_deadline=$((SECONDS + CONFIRM_TIMEOUT + 1))
fi
if [ -n "$lock_pid" ] && fm_pid_alive "$lock_pid" &&
{ [ "$lock_pid" != "$child" ] ||
{ [ -s "$WATCH_LOCK/pid-identity" ] && [ -e "$BEAT" ]; }; }; then
if healthy_watcher; then
if [ "$HEALTHY_PID" = "$child" ]; then
cycle_refresh_lock_before
if ! handling_generation=$(handling_successor_generation); then
cleanup_child
wait "$child" 2>/dev/null || true
cycle_log_append 1 none handling-handoff-failed none
echo "watcher: FAILED - established successor could not inspect handling state"
exit 1
fi
cycle_mark_predecessor_successor "started:$child"
if [ -n "$handling_generation" ]; then
echo "watcher: started pid=$child (beacon fresh) recovery-generation=$handling_generation"
else
echo "watcher: started pid=$child (beacon fresh)"
fi
wait "$child"
rc=$?
owned_child_finished "$rc"
exit $?
fi
# Another watcher won the singleton; our child stood down.
wait "$child"
rc=$?
owned_child_finished "$rc"
exit $?
fi
# Another watcher won the singleton; our child stood down.
wait "$child"
rc=$?
owned_child_finished "$rc"
exit $?
fi
if [ "$child_done" -eq 0 ] && ! fm_pid_alive "$child"; then
wait "$child"
Expand All @@ -581,15 +656,16 @@ while :; do
owned_child_finished "$rc"
exit $?
fi
[ "$(date +%s)" -ge "$deadline" ] && break
[ "$SECONDS" -ge "$confirm_deadline" ] && break
sleep 0.2
done

trap - HUP TERM INT
print_watch_output "$child_out"
# The ledger keeps the phase the child stalled in as the reason suffix.
cleanup_child
wait "$child" 2>/dev/null
rc=$?
cycle_log_append "$rc" "$(cycle_signal_name "$rc")" confirmation-timeout none
cycle_log_append "$rc" "$(cycle_signal_name "$rc")" "confirmation-timeout:$confirm_phase" none
echo "watcher: FAILED - no live watcher with a fresh beacon"
exit 1
6 changes: 3 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1102,10 +1102,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 fm-watch-arm allows a fresh watcher in each startup phase (fork, lock, identity, beacon) before reporting FAILED, where fork means the child is running but has not claimed the lock yet and is the phase named when no lock ever appears; the same value also bounds the wait for an already published healthy successor, spent there as one whole window rather than per phase; default 30 on Git Bash/MSYS
FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle
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_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure; the 35000 Windows default covers one MSYS confirmation window and not the arm's per-phase budget, so this adapter can still retire an arm whose child is progressing through its startup phases
FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached; the 35000 Windows default covers one MSYS confirmation window and not the arm's per-phase budget, so this adapter can still retire an arm whose child is progressing through its startup phases
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
20 changes: 20 additions & 0 deletions docs/verification/supervision.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,26 @@ ok - unacknowledged recovery is announced at most once per generation and the su
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=59357
```

### Git Bash/MSYS arm confirmation cost, 2026-09-17

Measured on Windows 11 Pro 10.0.26200 under Git Bash (MINGW64_NT-10.0-26200 3.6.9, bash 5.3.15), against an isolated `FM_HOME` with no crew, `FM_POLL=5`, and heartbeat and check cadences disabled, on an otherwise idle host.

```sh
FM_HOME=<scratch-home> FM_POLL=5 FM_HEARTBEAT=999999 FM_CHECK_INTERVAL=999999 bin/fm-watch.sh
FM_HOME=<scratch-home> FM_POLL=5 FM_HEARTBEAT=999999 FM_CHECK_INTERVAL=999999 bin/fm-watch-arm.sh
```

Observed, with the lock and beacon appearance read off `state/.watch.lock/pid` and `state/.last-watcher-beat` mtimes relative to the launch:

```text
bare watcher: lock pid at +5.2s to +5.8s, first beacon at +11.6s to +13.6s, 106 subshell forks before the first beacon
arm-launched: lock pid at +8.1s, first beacon at +17.7s, "watcher: started" at +19.9s
```

Observed guarantee: an arm-launched child forked from a bash whose parent is a native Windows process (MSYS reports its ppid as 1) held the singleton lock and advanced the beacon every poll for the whole observation window, so the child neither exits early nor is orphaned at the MSYS/native boundary; before the per-phase bound the same start left about 10s of headroom inside the 31s window, and the arm's own ten-fork poll iteration accounted for the difference between the bare and arm-launched first beat.
The arm-launched row above predates this change: the confirmation loop now reads each startup step with shell builtins and forks the health proof only when the lock names a live pid it must judge, so the arm's own contribution to its child's first beat is smaller than measured here.
`tests/fm-watcher-lock.test.sh` pins the per-phase bound with a stand-in watcher whose phase durations each fit the window while their sum does not, and its healthy-peer restart case uses a TERM-ignoring shell peer because Cygwin's `kill` cannot signal a native process and terminates it instead.

Deterministic entry points:

```sh
Expand Down
Loading
Loading