diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index e505b99f75..5334fb7087 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -588,9 +588,21 @@ fm_backend_expected_label_of_selector() { # return 0 } +# fm_backend_source_file: source one adapter path, reporting a missing or +# unreadable adapter as a plain nonzero return. Stock macOS Bash 3.2 treats a +# failed `.` as a fatal special-builtin error and exits the whole shell under +# `set -e` - even inside an `if !` condition - which would kill every caller +# before it could print its own missing-adapter refusal. Checking readability +# first keeps that failure recoverable on every supported Bash. +fm_backend_source_file() { # + [ -r "$1" ] || return 1 + # shellcheck source=/dev/null + . "$1" +} + # fm_backend_source: source the named backend's adapter file, once per shell. # Each adapter is an independently linted canonical root. The /dev/null source -# boundaries keep runtime dispatch from importing all five adapter ASTs into +# boundary keeps runtime dispatch from importing all five adapter ASTs into # every dispatcher consumer while preserving the runtime source operations. fm_backend_source() { # local name=$1 @@ -598,36 +610,31 @@ fm_backend_source() { # case "$name" in tmux) if [ -z "${_FM_BACKEND_TMUX_SOURCED:-}" ]; then - # shellcheck source=/dev/null - . "$FM_BACKEND_LIB_DIR/backends/tmux.sh" || return 1 + fm_backend_source_file "$FM_BACKEND_LIB_DIR/backends/tmux.sh" || return 1 _FM_BACKEND_TMUX_SOURCED=1 fi ;; herdr) if [ -z "${_FM_BACKEND_HERDR_SOURCED:-}" ]; then - # shellcheck source=/dev/null - . "$FM_BACKEND_LIB_DIR/backends/herdr.sh" || return 1 + fm_backend_source_file "$FM_BACKEND_LIB_DIR/backends/herdr.sh" || return 1 _FM_BACKEND_HERDR_SOURCED=1 fi ;; zellij) if [ -z "${_FM_BACKEND_ZELLIJ_SOURCED:-}" ]; then - # shellcheck source=/dev/null - . "$FM_BACKEND_LIB_DIR/backends/zellij.sh" || return 1 + fm_backend_source_file "$FM_BACKEND_LIB_DIR/backends/zellij.sh" || return 1 _FM_BACKEND_ZELLIJ_SOURCED=1 fi ;; orca) if [ -z "${_FM_BACKEND_ORCA_SOURCED:-}" ]; then - # shellcheck source=/dev/null - . "$FM_BACKEND_LIB_DIR/backends/orca.sh" || return 1 + fm_backend_source_file "$FM_BACKEND_LIB_DIR/backends/orca.sh" || return 1 _FM_BACKEND_ORCA_SOURCED=1 fi ;; cmux) if [ -z "${_FM_BACKEND_CMUX_SOURCED:-}" ]; then - # shellcheck source=/dev/null - . "$FM_BACKEND_LIB_DIR/backends/cmux.sh" || return 1 + fm_backend_source_file "$FM_BACKEND_LIB_DIR/backends/cmux.sh" || return 1 _FM_BACKEND_CMUX_SOURCED=1 fi ;; diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index fc289dd6e8..e2452f02c5 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -269,7 +269,7 @@ The report is the only thing that survives, so anything worth keeping must be in 4. Report status by appending one line: \`echo "{state}: {one short line}" >> $STATUS_FILE\` States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. - Each append wakes firstmate, so report sparingly: only phase changes a supervisor + Each append enters supervision, so report sparingly: only phase changes a supervisor would act on and the needs-decision/blocked/paused/done/failed states. No step-by-step FYI progress lines; firstmate reads your pane for that. Use \`$PAUSED_VERB: {why}\` - distinct from \`blocked:\` - ONLY when you are deliberately idling on a @@ -381,7 +381,7 @@ $RULE1 4. Report status by appending one line: \`echo "{state}: {one short line}" >> $STATUS_FILE\` States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. - Each append wakes firstmate, so report sparingly: only phase changes a supervisor + Each append enters supervision, so report sparingly: only phase changes a supervisor would act on (setup done, bug reproduced, fix implemented, validation passed) and the needs-decision/blocked/paused/done/failed states. No step-by-step FYI progress lines; firstmate reads your pane for that. diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index d80840f6a1..3168f833c3 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Shared wake classifier: the common source of truth for captain-relevant status # tests, declared-external-wait vocabulary, and the working/paused absorb -# classification that makes no-verb signal and stale-pane wakes safe to absorb. +# classification that makes routine working-progress signals and proven-active +# lifecycle/stale wakes safe to absorb. # Sourced by BOTH the always-on watcher # (bin/fm-watch.sh) and the away-mode daemon (bin/fm-supervise-daemon.sh) so the # overlapping triage policy lives in one place instead of two copies that can @@ -16,10 +17,10 @@ # The one exception is the absorb classification (crew_absorb_class and its # working/paused wrappers). It is NOT a pure status-file read: it reuses # bin/fm-crew-state.sh, which may make a bounded no-mistakes call, to decide -# whether a crew that just stopped its turn or went stale is working, deliberately -# paused, or neither. Callers run it ONLY on no-verb signal handling and first -# sighting of a stale hash, never on every wake, so the per-wake triage stays -# cheap. +# whether a crew behind an ambiguous lifecycle signal or stale pane is working, +# deliberately paused, or neither. Callers run it ONLY on ambiguous signal +# handling and first sighting of a stale hash, never on every wake, so the +# per-wake triage stays cheap. # Directory of this library, used to locate the sibling fm-crew-state.sh reader. # Resolved at source time from BASH_SOURCE so it works whether sourced by a @@ -32,10 +33,14 @@ _FM_CLASSIFY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" FM_CREW_STATE_BIN="${FM_CREW_STATE_BIN:-$_FM_CLASSIFY_LIB_DIR/fm-crew-state.sh}" # Captain-relevant status verbs. A status line carrying any of these is work -# firstmate must see. Lines without these verbs are no-verb signals: the watcher -# absorbs them only with positive provably-working evidence, while the daemon uses -# its away-mode classification. FM_CAPTAIN_RE overrides the whole set when a home -# needs a custom verb vocabulary; absent, this default applies. +# firstmate must see. In ordinary active-session mode, an ordinary direct report's +# status-only batch whose every latest event is `working:` is routine progress and +# is absorbed without a runtime busy proof; a persistent secondmate's `working:` +# report keeps its existing delivery because the stale and heartbeat backstops do +# not cover it. Other lines without these verbs are ambiguous signals: the +# watcher absorbs them only with positive provably-working evidence, while the +# daemon uses its away-mode classification. FM_CAPTAIN_RE overrides the whole set +# when a home needs a custom verb vocabulary; absent, this default applies. # # Free-text tokens (PR ready, checks green, ready in branch, merged) exist only for # legacy lines that lack a standard terminal verb. status_is_captain_relevant is @@ -301,8 +306,9 @@ window_to_task() { # captain-relevant last line; 1 otherwise. Pass the space-separated file list that # follows the "signal:" prefix. Non-.status arguments (e.g. .turn-ended markers, # which never carry a verb) are skipped. A 1 here is NOT "benign" on its own: a -# no-verb signal (a bare turn-end, a working: note) is only benign when the crew is -# also provably working (signal_crew_provably_working below); otherwise it surfaces. +# non-actionable result is NOT "benign" on its own: pure working-progress batches +# are benign by event semantics, while every other signal is benign only when the +# crew is also provably working (signal_crew_provably_working below). signal_reason_is_actionable() { # ... local f last for f in "$@"; do @@ -315,6 +321,49 @@ signal_reason_is_actionable() { # ... return 1 } +# Task kind recorded in a status file's sibling `.meta`, echoed as one token. +# A metadata record with no kind= field is an ordinary ship task and a status file +# with no metadata at all is unknown, matching bin/fm-watch.sh's window_kind. +status_file_kind() { # + local f=$1 meta kind + meta="${f%.status}.meta" + if [ -f "$meta" ]; then + kind=$(grep '^kind=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2- || true) + printf '%s' "${kind:-ship}" + return 0 + fi + printf 'unknown' +} + +# 0 (routine/absorb) only when the signal batch contains one or more status files, +# contains no lifecycle marker or unknown file shape, references no persistent +# secondmate, and every status file's latest event has the leading `working` verb. +# This is intentionally narrower than "not captain-relevant": paused, resolved, +# captain-held, missing, and malformed signals retain their existing conservative +# reconciliation path. +# A secondmate is excluded because routine absorb relies on the unchanged-pane +# stale path as its delayed backstop, and that path deliberately skips a +# secondmate endpoint unless it declared a pause (an idle secondmate agent is +# healthy by design), while the heartbeat backstop only rescans captain-relevant +# statuses. A secondmate `working [key=...]` line is also its documented sparse +# material phase report and a valid correlated answer to a marked request +# (bin/fm-brief.sh's charter contract, bin/fm-pending-reply-lib.sh), so it keeps +# the conservative provably-working path that used to deliver it. +# The always-on watcher uses this before consulting semantic busy state. Away mode +# does not use it because the daemon remains the sole triage owner there. +signal_is_routine_working_progress() { # ... + local f last seen="" + for f in "$@"; do + case "$f" in *.status) ;; *) return 1 ;; esac + [ -e "$f" ] || return 1 + case "$(status_file_kind "$f")" in secondmate) return 1 ;; esac + last=$(last_status_line "$f") + [ "$(status_line_verb "$last")" = working ] || return 1 + seen=1 + done + [ -n "$seen" ] +} + # Classify WHY an idle/stale crew MIGHT be safely absorbed instead of surfaced, # from bin/fm-crew-state.sh's one authoritative current-state line # ("state: · source: · "). Prints exactly one token: @@ -329,7 +378,7 @@ signal_reason_is_actionable() { # ... # authoritatively (not the status log) is what keeps run-step precedence: a crew # that appended paused: but then STARTED a run reports working, never paused. # NOT a pure read: fm-crew-state.sh may make a bounded no-mistakes call, so callers -# run it only on no-verb signal and first-sighting stale paths, never every wake. +# run it only on ambiguous signal and first-sighting stale paths, never every wake. # FM_CREW_STATE_BIN lets tests stub the verdict. crew_absorb_class() { # local id=$1 line state src @@ -347,11 +396,14 @@ crew_absorb_class() { # # 0 if crew shows POSITIVE evidence it is still working (crew_absorb_class # reports `working`). This is the "provably working" predicate at the heart of -# absorb-only-when-provably-working: a no-verb turn-end or stale wake is absorbed +# absorb-only-when-provably-working: an ambiguous signal (a bare turn-end, an +# unknown file shape, a non-working nonterminal event) or a stale wake is absorbed # ONLY when this returns 0, and SURFACED otherwise (the crew may be done, waiting -# on a decision, or wedged). For stale panes it is checked before trusting the -# status log so a pre-validation captain-relevant line does not override an active -# run. See crew_absorb_class for the exact working/paused/none decision. +# on a decision, or wedged). Routine working-progress batches never reach this +# predicate; signal_is_routine_working_progress absorbs them on event semantics +# alone. For stale panes it is checked before trusting the status log so a +# pre-validation captain-relevant line does not override an active run. See +# crew_absorb_class for the exact working/paused/none decision. crew_is_provably_working() { # [ "$(crew_absorb_class "$1")" = working ] } @@ -363,11 +415,13 @@ crew_is_paused() { # [ "$(crew_absorb_class "$1")" = paused ] } -# 0 (benign/absorb) if EVERY task referenced by a no-verb "signal:" wake is provably -# working; 1 (actionable/surface) if any is not, or no task can be resolved. Pass the -# same space-separated file list as signal_reason_is_actionable. Files are mapped to -# task ids by stripping the .status / .turn-ended suffix; a no-verb wake with nothing -# provably working must surface, so an empty/unresolvable list returns 1. +# 0 (benign/absorb) if EVERY task referenced by an ambiguous "signal:" wake is +# provably working; 1 (actionable/surface) if any is not, or no task can be +# resolved. Pure working-progress batches are handled before this predicate. +# Pass the same space-separated file list as signal_reason_is_actionable. Files +# are mapped to task ids by stripping the .status / .turn-ended suffix; a signal +# with nothing provably working must surface, so an empty/unresolvable list +# returns 1. signal_crew_provably_working() { # ... local f base task seen="" for f in "$@"; do diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 2cbd3c7776..79b9a55d10 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -2,18 +2,25 @@ # Firstmate watcher. # Classifies supervision wakes in bash. In normal mode it absorbs benign wakes # and keeps blocking; it queues and exits only for actionable wakes. -# The no-verb signal and stale path is absorb-only-when-provably-working: a wake -# is absorbed only when the crew shows POSITIVE evidence it is still working (an -# actively-running no-mistakes step, or a backend busy signal), and surfaced -# otherwise, so a crew that finishes (or stops and waits) without a current -# working signal is never silently swallowed. A declared external-wait pause is +# An ordinary direct report's status-only signal batch whose latest events are all +# `working:` is routine progress and is absorbed in ordinary active-session mode +# without consulting runtime busy state; a persistent secondmate's `working:` +# report is excluded because the stale loop deliberately skips its idle endpoint +# and the heartbeat backstop only rescans captain-relevant statuses, so nothing +# else would deliver it. Bare turn-end, unknown-shape signal, and stale paths remain +# absorb-only-when-provably-working: they are absorbed only when the crew shows +# POSITIVE evidence it is still working (an actively-running no-mistakes step, or +# a backend busy signal), and surfaced otherwise, so a crew that finishes (or +# stops and waits) is never silently swallowed. A declared external-wait pause is # the separate idle absorb case and re-surfaces only on its long bounded cadence, -# although its initial no-verb status signal still surfaces in normal mode. +# although its initial ambiguous status signal still surfaces in normal mode. # While state/.afk exists, the daemon owns triage and this watcher queues and exits # on every wake. Printed reason lines: # signal: ... status/turn-end signals, surfaced when a listed status -# has a captain-relevant verb OR a no-verb signal's crew -# is not provably working, unless afk is active +# has a captain-relevant verb OR a non-routine signal's +# crew is not provably working, unless afk is active; +# an ordinary direct report's pure working-progress +# status batch is absorbed # stale: a provably-working stale is ALWAYS absorbed (with a wedge # timer) regardless of what the status log says - an active # run-step or busy pane outranks even a captain-relevant log @@ -22,8 +29,11 @@ # external-wait pause is absorbed instead with its own long # re-surface cadence, never as a wedge. Only when neither # absorb class applies does the log's last line decide: -# terminal (captain-relevant) or non-terminal (no verb), -# both surfaced at once. A provably-working stale past the +# terminal (captain-relevant) or non-terminal (no +# captain-relevant verb), both surfaced at once. The +# routine working-progress absorb is a signal-path rule +# only and never suppresses a stale surface. A +# provably-working stale past the # wedge threshold also surfaces, with an "escalation N" # count in the reason; at FM_WEDGE_DEMAND_INSPECT_COUNT # consecutive escalations on the SAME pane, the reason @@ -123,17 +133,23 @@ SIGNAL_GRACE=${FM_SIGNAL_GRACE:-30} # seconds to linger after a signal so trai # working: note or turn-end while a pipeline runs, a no-change heartbeat). Rather # than wake firstmate's LLM for each, this watcher classifies every wake in bash # and ABSORBS the benign majority - it advances the suppression marker, logs to a -# debug log, and keeps blocking WITHOUT enqueuing or exiting. The no-verb signal -# / stale path is absorb-only-when-provably-working: such a wake is absorbed ONLY -# while the crew shows positive evidence it is still working (an actively-running -# no-mistakes step, or a busy pane, via crew_is_provably_working over -# fm-crew-state.sh); a crew that stopped its turn with no running pipeline and no -# busy pane is SURFACED, so a finish reported only through interactive pane menus -# (no done: status) is never swallowed. An ACTIONABLE wake (a captain-relevant -# signal, a no-verb signal whose crew is not provably working, any check, a stale -# pane whose crew is not provably working, a provably-working stale past the -# threshold, or anything unknown) is written to the durable queue and exits, which -# is what wakes the LLM through the background-task completion. The same classifier +# debug log, and keeps blocking WITHOUT enqueuing or exiting. An ordinary direct +# report's pure working-progress status batch is absorbed from its event semantics +# alone, so missing or unverified runtime busy state cannot turn routine progress +# into an LLM wake; a persistent secondmate's working: report is excluded from +# that absorb and stays on the provably-working path, because the stale loop skips +# an idle secondmate endpoint by design and the heartbeat backstop only rescans +# captain-relevant statuses. Bare turn-end, unknown-shape signal, and stale paths remain +# absorb-only-when-provably-working: they are absorbed ONLY while the crew shows +# positive evidence it is still working (an actively-running no-mistakes step, or +# a busy pane, via crew_is_provably_working over fm-crew-state.sh); a crew that +# stopped its turn with no running pipeline and no busy pane is SURFACED, so a +# finish reported only through interactive pane menus (no done: status) is never +# swallowed. An ACTIONABLE wake (a captain-relevant signal, an ambiguous signal +# whose crew is not provably working, any check, a stale pane whose crew is not +# provably working, a provably-working stale past the threshold, or anything +# unknown) is written to the durable queue and exits, which is what wakes the LLM +# through the background-task completion. The same classifier # (fm-classify-lib.sh) backs the away-mode daemon; while state/.afk exists the # daemon owns triage, so this watcher reverts to one-shot (enqueue + exit on every # wake) and never double-triages - and never runs the costly provably-working read. @@ -880,18 +896,31 @@ EOF # Triage: a signal is ACTIONABLE when any of these holds (cheapest first): # - the away-mode daemon owns triage (afk) and wants every wake; # - any status file carries a captain-relevant verb; - # - or it is a no-verb wake (a bare turn-end, a working: note) whose crew is - # NOT provably working - the crew stopped its turn with no actively-running - # pipeline and no busy pane, so it may be done (even via an interactive menu - # that wrote no done: status), waiting on a decision, or wedged. Absorbing - # such a turn-end is exactly the swallowed-finish this change guards against. - # Actionable -> enqueue, advance .seen-* markers, exit. Benign (a no-verb wake - # whose crew IS provably working) in always-on mode -> advance the markers so it - # will not re-fire, log, and keep blocking without enqueuing. The provably-working - # check is the only costly one (it may run a bounded no-mistakes call), so the || - # ordering evaluates it ONLY for a non-afk, no-captain-verb signal. + # - or it is an ambiguous wake (a bare turn-end, unknown file shape, or + # non-working nonterminal event) whose crew is NOT provably working - the + # crew stopped its turn with no actively-running pipeline and no busy pane, + # so it may be done (even via an interactive menu that wrote no done: + # status), waiting on a decision, or wedged. Absorbing such a turn-end is + # exactly the swallowed-finish boundary this rule guards. + # An ordinary direct report's status-only batch whose every latest event is + # working: is routine progress and bypasses the runtime-state read; a batch + # naming a persistent secondmate never qualifies, because no later backstop + # would deliver its sparse phase report or correlated reply. + # Actionable -> enqueue, advance .seen-* + # markers, exit. Benign -> advance the markers so it will not re-fire, log, + # and keep blocking without enqueuing. The provably-working check is the only + # costly one (it may run a bounded no-mistakes call), so it runs ONLY for a + # non-afk, non-actionable, non-working-progress signal. + signal_actionable=0 # shellcheck disable=SC2086 # $files is a space-separated status-path list (ids carry no spaces) - if afk_present || signal_reason_is_actionable $files || ! signal_crew_provably_working $files; then + if afk_present || signal_reason_is_actionable $files; then + signal_actionable=1 + elif signal_is_routine_working_progress $files; then + : + elif ! signal_crew_provably_working $files; then + signal_actionable=1 + fi + if [ "$signal_actionable" -eq 1 ]; then while IFS=$(printf '\t') read -r sf sig f; do [ -n "$sf" ] || continue fm_wake_append signal "$(basename "$f")" "$reason" || exit 1 diff --git a/docs/architecture.md b/docs/architecture.md index 95b0c2ff13..e77f038d9d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,7 @@ firstmate's always-loaded operating contract and routing index for conditional p ## Event-driven supervision A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet, classifies detected wakes in bash, and wakes the first mate only when something is actionable. -Actionable wakes include captain-relevant status signals, no-verb signals whose crew is not provably working, authenticated check output such as PR merge polling or an X-mode mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS`, declared external waits that remain paused past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits. +Actionable wakes include captain-relevant status signals, ambiguous lifecycle signals whose crew is not provably working, authenticated check output such as PR merge polling or an X-mode mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS`, declared external waits that remain paused past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits. Repeated provably-working stale escalations on the same unchanged pane add an escalation count to the wake reason and, at `FM_WEDGE_DEMAND_INSPECT_COUNT`, a `demand-deep-inspection` marker. A busy pane is otherwise exempt from staleness, but only until its latest `state/.turn-ended` marker reaches `FM_BUSY_TURN_MAX_SECS`, or its `state/.meta` spawn record reaches that age before any turn completes; past that bound it is routed through the same wedge escalation, with the identical reason, escalation count, and `demand-deep-inspection` marker, for inspection only - never an automatic interrupt, signal, or restart. Those actionable wakes are written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed process exit can be recovered by draining the queue. @@ -17,11 +17,14 @@ When a canonical validated PR poll returns exactly `merged`, the watcher appends The receipt makes retirement safely retryable across restarts: fixed-path recovery revalidates the same evidence, removes the runnable check first, removes its registration and data sidecars, removes the receipt last, and preserves task metadata including `pr=` and `pr_head=`. A concurrent replacement remains armed, every non-merged or invalid observation remains unchanged, and retirement never performs task or persistent-secondmate cleanup. `bin/fm-pr-lib.sh` owns the receipt format and strict identity mechanics, while `bin/fm-watch.sh` owns queue-before-retirement ordering. -No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step attributed to that crew's current code, or an exact busy verdict from the semantic busy-state contract. +In ordinary active-session mode, an ordinary direct report's status-only signal batch whose every latest event is `working:` is routine progress and is absorbed without a runtime busy proof. +Bare turn-ended signals and other ambiguous signal shapes are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step attributed to that crew's current code, or an exact busy verdict from the semantic busy-state contract. +If such a worker writes `working:` and then stops with no terminal event, the unchanged-pane stale path still surfaces it after three total pane observations, adding at most two `FM_POLL` intervals after the progress signal is classified, or 30 seconds at the defaults. +A persistent secondmate's `working:` report is excluded from that routine absorb and keeps its existing provably-working delivery, because the stale path deliberately skips an idle secondmate endpoint and the heartbeat backstop only rescans captain-relevant statuses, so no later path would deliver its sparse phase report or its correlated answer to a marked request. A crew that declares `paused:` for a known external wait is separately absorbed while idle and re-surfaced only on the longer pause cadence, rather than being treated as a possible wedge. For an ordinary crew that has stopped, the normal-mode watcher first surfaces one stale wake, then applies that same cadence to an unchanged `paused:` or durable `captain-held` endpoint only when the backend confidently reports its agent dead. Live or inconclusive liveness remains fail-open at that initial surface, and the secondmate idle-endpoint exemption is unchanged. -Its initial normal-mode status signal still surfaces through the no-verb path, while away mode self-handles that routine signal and owns the later recheck. +Its initial normal-mode status signal still surfaces through the ambiguous-signal path, because a `paused:` event is neither captain-relevant nor routine working progress, while away mode self-handles that routine signal and owns the later recheck. Fresh stale panes use the same current-state read before trusting the status log, so an active run or a proven busy worker outranks an old captain-relevant status-log line left behind before validation. No-change heartbeats are also benign. Absorbed wakes advance their suppression markers, log to `state/.watch-triage.log`, and keep the watcher blocking without a queue record or LLM turn. @@ -60,6 +63,9 @@ That block owns the live wait shape for the running primary harness: Claude's St On `attached` it stays live across identity-matched successors, and an unexplained clean child close either attaches to a verified healthy successor or becomes the typed nonzero `watcher: FAILED - cycle ended without an actionable reason` result. The arm layer records one bounded lifecycle row per observed cycle in `state/.watch-cycle-exits.log`; `state/.watch-triage.log` remains exclusively the absorbed-wake debug log. Pi and OpenCode verify session-lock ownership and launch one singleton successor from their child-close handlers before delivering an actionable wake prompt, with bounded exponential retry for failed restoration. +On Pi, guard notifications and the registered `fm_watch_arm_pi` tool prove that the tracked primary extensions loaded; they do not make Calm a delivery filter or change watcher classification. +The watcher extension turns each emitted actionable reason into an ordinary user-role follow-up, and [Calm](calm.md) can hide that row without suppressing its model input or assistant reply, so unnecessary routine turns must be prevented before delivery by keeping routine progress out of the watcher reason stream. +Missing, failed, or unhealthy cycle notifications come from the separate continuity and turn-end-guard path and are not evidence that a routine status signal was misclassified unless correlated runtime evidence connects the two. Claude's `bin/fm-claude-stop-autoarm.sh` hook fires on every Stop and, when the home is eligible and still needs supervision, claims one home-scoped cycle, foregrounds the arm wrapper, and translates an actionable close or typed failure into one exit-2 rewake. [`watcher-continuity.md`](watcher-continuity.md) owns Claude's residual active-turn coverage and watcher-status command-gating boundary. The existing turn-end guard remains the final backstop for all five harness-engine protocols, with pi-signed sharing Pi's protocol and the `--claude` mode cooperating with the auto-arm claim. @@ -73,7 +79,7 @@ The guard covers the main primary and genuinely marked secondmate homes, exempts A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-external-wait vocabulary, and status-scan primitives. Terminal verbs remain captain-relevant, while a nonterminal progress verb cannot become terminal merely because its prose contains a legacy free-text token such as `merged`; bare legacy free-text lines remain compatible. -The always-on watcher also uses that library's absorb classification on no-verb signals and first-sighting stale panes before status-log terminality is trusted, while the daemon maintains distinct wedge and declared-pause recheck cadences. +The always-on watcher also uses that library's routine-progress and absorb classifications on status signals, ambiguous lifecycle signals, and first-sighting stale panes before status-log terminality is trusted, while the daemon maintains distinct wedge and declared-pause recheck cadences. In away mode, seen-status dedupe does not clear possible-wedge aging for nonterminal progress, so housekeeping still re-escalates an unchanged idle pane at the configured bound. The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest using the canonical `away-supervisor` kind from `bin/fm-operational-input.sh` so firstmate can distinguish it structurally from real messages. Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index f888319489..7c8280e1a5 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -199,6 +199,63 @@ EOF pass "Pi custom tool exposes repair-only metadata and returns automatic-continuation guidance" } +# A correctly loaded primary extension is delivery machinery, not evidence that +# every observed status deserves a model turn. When the watcher producer remains +# live and emits no actionable reason, the registered tool must create no +# user-role follow-up. Missing-cycle and arm-failure prompts are covered by the +# separate close/retry tests below and must not be conflated with this quiet path. +test_pi_quiet_arm_sends_no_followup() { + local repo home plugin stop out status + repo="$TMP_ROOT/pi-quiet-arm-root" + home="$TMP_ROOT/pi-quiet-arm-home" + stop="$TMP_ROOT/pi-quiet-arm.stop" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +trap 'exit 0' TERM INT +while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +let tool = null; +const prompts = []; +const pi = { + on() {}, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + prompts.push(message); + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +if (!tool) throw new Error("fm_watch_arm_pi was not registered by the loaded extension"); +const result = await tool.execute("tool-call-quiet", {}, undefined, undefined, {}); +if (!result.content[0]?.text.includes("started Pi extension arm child")) { + throw new Error(`quiet arm did not start: ${result.content[0]?.text}`); +} +await new Promise((resolve) => setTimeout(resolve, 250)); +if (prompts.length !== 0) { + throw new Error(`quiet watcher produced ${prompts.length} user-role follow-ups: ${JSON.stringify(prompts)}`); +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +EOF +) + status=$? + expect_code 0 "$status" "a loaded Pi extension must not create a model turn before an actionable watcher reason" + [ -z "$out" ] || fail "Pi quiet-arm test printed output: $out" + pass "loaded Pi extension keeps a quiet watcher cycle turn-free; continuity alarms remain a separate path" +} + test_pi_redundant_tool_call_is_owned_noop() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-redundant-tool-root" @@ -2126,6 +2183,7 @@ EOF test_pi_extension_reports_external_healthy_watcher test_pi_tool_returns_agent_tool_result +test_pi_quiet_arm_sends_no_followup test_pi_redundant_tool_call_is_owned_noop test_pi_scheduled_retry_call_is_owned_noop test_pi_actionable_close_starts_single_successor_before_delivery diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index cfb1578fc4..ce2a2c43a8 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -430,9 +430,19 @@ if [ "$1" = "-f" ] && [ "$2" = "%Lp" ]; then fi exit 1 SH + # The oldest worker holds its slot until the replacement fixture reports that + # it started, so "was the freed slot refilled before the oldest worker + # finished?" is a deterministic handshake instead of a race against the + # runner's own per-worker timestamp overhead. The wait is bounded so a + # regressed scheduler that waits for the oldest worker still fails the + # assertion below rather than hanging. cat >"$repo/$a" <<'SH' #!/usr/bin/env bash -sleep 0.5 +i=0 +while [ "$i" -lt 100 ] && [ ! -e "$SCHED_EVIDENCE/replacement-started" ]; do + sleep 0.05 + i=$((i + 1)) +done touch "$SCHED_EVIDENCE/slow-done" echo "ok - slow fixture" SH @@ -441,12 +451,16 @@ SH sleep 0.05 echo "ok - fast fixture" SH + # Read the oldest worker's completion marker before releasing it, so the + # answer cannot change between the check and the handshake. cat >"$repo/$c" <<'SH' #!/usr/bin/env bash if [ -e "$SCHED_EVIDENCE/slow-done" ]; then + touch "$SCHED_EVIDENCE/replacement-started" echo "not ok - scheduler waited for oldest worker" exit 1 fi +touch "$SCHED_EVIDENCE/replacement-started" echo "ok - replacement fixture started before slow fixture finished" SH chmod +x "$runner" "$repo/$a" "$repo/$b" "$repo/$c" "$fake_bin/stat" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index c10565bc8a..a34bb52bd7 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -5,8 +5,10 @@ # wake, so firstmate's LLM re-arms once per actionable event instead of once per # wake. These tests cover the classifier predicates as pure functions, then drive # a real fm-watch.sh subprocess to assert the behavioral contract: -# provably-working no-verb wakes absorbed (no exit, no queue entry, suppressor -# advanced, beacon fresh), stopped-crew no-verb wakes surfaced (queue + exit), +# an ordinary direct report's pure working-progress signals absorbed without a +# runtime busy proof while a secondmate's working: report is still delivered, +# provably-working ambiguous wakes absorbed (no exit, no queue entry, suppressor +# advanced, beacon fresh), stopped-crew ambiguous wakes surfaced (queue + exit), # provably-working stale panes absorbed-then-escalated past the threshold, # terminal-looking stale status lines overridden by an active run, the heartbeat # backstop fail-safe, and afk coherence (no double-triage while the away-mode @@ -127,6 +129,40 @@ test_signal_reason_is_actionable_classifier() { pass "signal_reason_is_actionable: benign absorbed, captain verbs and coalesced batches surfaced" } +test_signal_is_routine_working_progress_classifier() { + local dir state + dir=$(make_case classify-working-progress); state="$dir/state" + printf 'working: researching the incident\n' > "$state/a.status" + printf 'working [key=phase2]: validating the correction\n' > "$state/b.status" + : > "$state/a.turn-ended" + printf 'resolved: an earlier decision closed\n' > "$state/resolved.status" + signal_is_routine_working_progress "$state/a.status" \ + || fail "a single working status was not classified as routine progress" + signal_is_routine_working_progress "$state/a.status" "$state/b.status" \ + || fail "an all-working status batch was not classified as routine progress" + ! signal_is_routine_working_progress "$state/a.status" "$state/a.turn-ended" \ + || fail "a batch containing a turn-end was classified as pure working progress" + ! signal_is_routine_working_progress "$state/a.status" "$state/resolved.status" \ + || fail "a non-working nonterminal event was classified as pure working progress" + ! signal_is_routine_working_progress "$state/missing.status" \ + || fail "a missing status file was classified as pure working progress" + ! signal_is_routine_working_progress \ + || fail "an empty signal batch was classified as pure working progress" + # Routine absorb is scoped to ordinary direct reports: a persistent secondmate's + # working: report has no stale-loop or heartbeat backstop, so it must stay on the + # conservative provably-working path whether it stands alone or rides a batch. + printf 'window=test:fm-b\nkind=ship\n' > "$state/b.meta" + printf 'window=test:fm-mate\nkind=secondmate\n' > "$state/mate.meta" + printf 'working [key=audit]: findings in data/audit.md corr=req-7\n' > "$state/mate.status" + signal_is_routine_working_progress "$state/b.status" \ + || fail "an ordinary kind=ship working status was not classified as routine progress" + ! signal_is_routine_working_progress "$state/mate.status" \ + || fail "a secondmate working report was classified as routine progress" + ! signal_is_routine_working_progress "$state/b.status" "$state/mate.status" \ + || fail "a batch naming a secondmate was classified as pure working progress" + pass "signal_is_routine_working_progress: only nonempty all-working ordinary-task status batches are routine" +} + test_stale_is_terminal_classifier() { local dir state dir=$(make_case classify-stale); state="$dir/state" @@ -373,7 +409,7 @@ test_turn_ended_provably_working_absorbed() { pass "a bare turn-end whose crew is provably working (busy pane) is absorbed" } -# --- a no-verb signal whose crew is NOT provably working SURFACES ------------- +# --- an ambiguous signal whose crew is NOT provably working SURFACES --------- # This is the swallowed-finish fix: a crew that finished (or stopped and waits) # reports its final turn-end with no captain-relevant status and no running # pipeline, so the wake must surface instead of being absorbed. @@ -395,42 +431,90 @@ test_turn_ended_not_working_surfaced() { pass "a bare turn-end whose crew is not provably working is surfaced (the swallowed-finish fix)" } -test_working_note_not_working_surfaced() { +test_working_note_unknown_runtime_absorbed() { + local dir state fakebin out status_file pid i + dir=$(make_case working-note-unknown); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + printf 'kind=ship\n' > "$state/task.meta" + printf 'working: researching the unknown-busy incident\n' > "$status_file" + # Exact incident boundary: Codex has no verified semantic busy source, so the + # authoritative runtime verdict is unknown even though the fresh event itself + # is explicitly nonterminal progress. A loaded Pi watcher extension would turn + # any emitted reason into an ordinary user-role follow-up, and Calm would only + # hide its row, so the signal must be absorbed here without consulting that + # unavailable busy proof. + export FM_FAKE_CREW_STATE='state: unknown · source: none · no verified semantic busy source' + watch_bg "$state" "$fakebin" "$out" + pid=$! + i=0 + while [ "$i" -lt 40 ] && [ ! -s "$state/.seen-task_status" ]; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + i=$((i + 1)) + done + kill -0 "$pid" 2>/dev/null || { reap "$pid"; fail "watcher exited for routine working progress with unknown busy state: $(cat "$out")"; } + [ -s "$state/.seen-task_status" ] || { reap "$pid"; fail "absorbed working progress did not advance its .seen-* suppressor"; } + [ ! -s "$out" ] || { reap "$pid"; fail "unknown-busy working progress printed a watcher reason: $(cat "$out")"; } + [ ! -s "$state/.wake-queue" ] || { reap "$pid"; fail "unknown-busy working progress enqueued a durable wake"; } + reap "$pid" + unset FM_FAKE_CREW_STATE + pass "routine working progress is absorbed with unknown runtime busy state (no reason, queue, or model wake)" +} + +# The counterpart boundary: a persistent secondmate's working: line is its +# documented sparse material phase report and a valid correlated answer to a +# marked request, and nothing else would ever deliver it - the stale loop skips an +# idle secondmate endpoint by design, and the heartbeat rescan only looks at +# captain-relevant statuses. So routine absorb must not apply to it, even with the +# same unknown runtime busy state that absorbs an ordinary task's progress above. +test_secondmate_working_note_surfaced() { local dir state fakebin out drain_out status_file pid - dir=$(make_case working-note-stopped); state="$dir/state"; fakebin="$dir/fakebin" + dir=$(make_case secondmate-working-report); state="$dir/state"; fakebin="$dir/fakebin" out="$dir/watch.out"; drain_out="$dir/drain.out" - status_file="$state/task.status" - printf 'working: compiling step 2\n' > "$status_file" - # A non-no-mistakes crew (no run) whose pane went idle: fm-crew-state falls back - # to the stale working: status-log line. That is NOT positive evidence, so the - # wake must surface - these users must never be left hanging. - export FM_FAKE_CREW_STATE='state: working · source: status-log · working: compiling step 2' + status_file="$state/mate.status" + printf 'kind=secondmate\n' > "$state/mate.meta" + printf 'working [key=audit]: findings in data/audit.md corr=req-7\n' > "$status_file" + export FM_FAKE_CREW_STATE='state: unknown · source: none · no verified semantic busy source' watch_bg "$state" "$fakebin" "$out" pid=$! - wait_for_exit "$pid" 40 || fail "watcher did not surface a working: note whose crew has no running pipeline and an idle pane" - grep -F "signal: $status_file" "$out" >/dev/null || fail "watcher did not print the surfaced working: signal" - FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after the surfaced working: note failed" - grep "$(printf '\tsignal\t')" "$drain_out" | grep -F "$status_file" >/dev/null || fail "surfaced working: note was not queued" - [ -s "$state/.seen-task_status" ] || fail "surfaced working: note did not advance its .seen-* suppressor" - pass "a no-verb working: note whose crew is idle with no running pipeline is surfaced" + wait_for_exit "$pid" 40 || fail "watcher did not surface a secondmate working: report" + grep -F "signal: $status_file" "$out" >/dev/null || fail "watcher did not print the surfaced secondmate signal" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after the secondmate report failed" + grep "$(printf '\tsignal\t')" "$drain_out" | grep -F "$status_file" >/dev/null \ + || fail "secondmate working: report was not queued" + [ -s "$state/.seen-mate_status" ] || fail "surfaced secondmate report did not advance its .seen-* suppressor" + unset FM_FAKE_CREW_STATE + pass "a secondmate working: report is still delivered (no stale or heartbeat backstop covers it)" } # --- actionable wakes are surfaced (queue + exit) --------------------------- -test_actionable_signal_surfaced() { - local dir state fakebin out drain_out status_file pid - dir=$(make_case actionable-signal); state="$dir/state"; fakebin="$dir/fakebin" +test_actionable_and_mixed_signals_surfaced() { + local dir state fakebin out drain_out pid file queued + dir=$(make_case actionable-mixed-signal); state="$dir/state"; fakebin="$dir/fakebin" out="$dir/watch.out"; drain_out="$dir/drain.out" - status_file="$state/task.status" - printf 'working: setup\nneeds-decision: pick A or B\n' > "$status_file" + printf 'working: routine progress in the same batch\n' > "$state/a-working.status" + printf 'done: implementation complete\n' > "$state/b-done.status" + printf 'needs-decision: pick A or B\n' > "$state/c-needs.status" + printf 'blocked: missing required access\n' > "$state/d-blocked.status" + printf 'failed: focused validation failed\n' > "$state/e-failed.status" + export FM_FAKE_CREW_STATE='state: unknown · source: none · no verified semantic busy source' watch_bg "$state" "$fakebin" "$out" pid=$! - wait_for_exit "$pid" 40 || fail "watcher did not exit for an actionable needs-decision signal" - grep -F "signal: $status_file" "$out" >/dev/null || fail "watcher did not print the actionable signal reason" + wait_for_exit "$pid" 40 || fail "watcher did not exit for an actionable mixed signal batch" + for file in a-working b-done c-needs d-blocked e-failed; do + grep -F "$state/$file.status" "$out" >/dev/null \ + || fail "watcher reason omitted $file.status from the mixed batch" + done FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after the actionable signal failed" - grep "$(printf '\tsignal\t')" "$drain_out" | grep -F "$status_file" >/dev/null || fail "actionable signal was not queued" - [ -s "$state/.hb-surfaced-task" ] || fail "actionable signal did not record the surfaced marker" - pass "captain-relevant signal is surfaced (queue + exit) and marked surfaced" + queued=$(grep -c "$(printf '\tsignal\t')" "$drain_out" || true) + [ "$queued" -eq 5 ] || fail "mixed batch did not preserve all five durable signal records (got $queued)" + for file in b-done c-needs d-blocked e-failed; do + [ -s "$state/.hb-surfaced-$file" ] || fail "$file actionable status did not record the surfaced marker" + done + unset FM_FAKE_CREW_STATE + pass "done, needs-decision, blocked, failed, and a mixed working+actionable batch surface immediately" } test_terminal_stale_surfaced() { @@ -576,7 +660,7 @@ test_nonterminal_stale_provably_working_absorbed_then_escalated() { # non-no-mistakes crew, or any crew with no running pipeline) are never left hanging. test_nonterminal_stale_not_working_surfaced() { - local dir state fakebin out drain_out capture_file window key pane_hash sig pid + local dir state fakebin out drain_out capture_file window key pane_hash sig pid started elapsed dir=$(make_case nonterminal-stale-stopped); state="$dir/state"; fakebin="$dir/fakebin" out="$dir/watch.out"; drain_out="$dir/drain.out"; capture_file="$dir/pane.txt" window="test:fm-stopped" @@ -588,24 +672,28 @@ test_nonterminal_stale_not_working_surfaced() { sig=$(seen_sig "$state/stopped.status"); printf '%s' "$sig" > "$state/.seen-stopped_status" key=$(printf '%s' "$window" | tr ':/.' '___') pane_hash=$(hash_text "idle prompt, finished") - printf '%s' "$pane_hash" > "$state/.hash-$key" - printf '1\n' > "$state/.count-$key" # No running pipeline; the pane is idle. NOT provably working. export FM_FAKE_CREW_STATE='state: unknown · source: none · no current-state source available' - # Even with a high wedge threshold, a not-provably-working stale surfaces at once. + # This is the counterexample after a routine working signal was absorbed: begin + # with no pane-history seed and prove the existing stable-pane contract still + # surfaces the stopped worker after three observations (two poll intervals after + # the first observation), without waiting for the wedge threshold. + started=$(date +%s) PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & pid=$! wait_for_exit "$pid" 40 || fail "watcher did not surface a not-provably-working non-terminal stale at once" + elapsed=$(( $(date +%s) - started )) + [ "$elapsed" -le 4 ] || fail "stopped worker exceeded the three-observation stale bound (${elapsed}s at FM_POLL=1)" grep -Fx "stale: $window" "$out" >/dev/null || fail "watcher did not print the immediate stale wake" grep -F "possible wedge" "$out" >/dev/null && fail "an immediate stopped-crew stale was mislabeled a wedge" [ "$(cat "$state/.stale-$key" 2>/dev/null || true)" = "$pane_hash" ] || fail "stale suppressor was not advanced on surface" [ ! -e "$state/.stale-since-$key" ] || fail "stale-since timer should not be set when surfacing immediately" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after the immediate stale failed" grep "$(printf '\tstale\t')" "$drain_out" | grep -F "$window" >/dev/null || fail "immediate stale wake was not queued" - pass "a not-provably-working non-terminal stale is surfaced immediately (never left to wait out the timer)" + pass "a worker left on working: is surfaced within the three-observation stale bound (never left to wait out the wedge timer)" } # --- non-terminal stale, crew DECLARED a pause: absorbed, re-surfaced on a long @@ -1800,6 +1888,7 @@ test_afk_paused_changed_pane_hands_off_plain_stale() { } test_signal_reason_is_actionable_classifier +test_signal_is_routine_working_progress_classifier test_stale_is_terminal_classifier test_scan_captain_relevant_statuses_classifier test_classifier_primitives @@ -1810,8 +1899,9 @@ test_signal_crew_provably_working_classifier test_provably_working_signal_absorbed test_turn_ended_provably_working_absorbed test_turn_ended_not_working_surfaced -test_working_note_not_working_surfaced -test_actionable_signal_surfaced +test_working_note_unknown_runtime_absorbed +test_secondmate_working_note_surfaced +test_actionable_and_mixed_signals_surfaced test_terminal_stale_surfaced test_stale_terminal_status_overridden_by_active_run test_nonterminal_stale_provably_working_absorbed_then_escalated diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index e741ec21e8..6c7d800314 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -438,14 +438,24 @@ test_watch_restart_rejects_reused_pid() { } test_watch_restart_attaches_to_healthy_peer() { - local dir state fakebin out peer identity armpid status i + local dir state fakebin out peer identity armpid status i ready dir=$(make_case restart-healthy-peer) state="$dir/state" fakebin="$dir/fakebin" out="$dir/restart.out" + ready="$dir/peer-ready" mark_pr_check_migration_complete "$state" - node -e 'process.on("SIGTERM", () => {}); setTimeout(() => {}, 300000)' & + # --restart TERMs the recorded watcher first, so the peer only stands in for a + # healthy holder once its SIGTERM handler is actually installed. Wait for the + # peer's own ready file instead of racing Node's startup with the arm's TERM. + FM_PEER_READY="$ready" node -e 'process.on("SIGTERM", () => {}); require("fs").writeFileSync(process.env.FM_PEER_READY, "ready\n"); setTimeout(() => {}, 300000)' & peer=$! + i=0 + while [ "$i" -lt 100 ] && [ ! -s "$ready" ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -s "$ready" ] || fail "peer never became TERM-resistant" identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$peer") || fail "could not identify peer pid" mkdir "$state/.watch.lock" printf '%s\n' "$peer" > "$state/.watch.lock/pid" diff --git a/tests/wake-helpers.sh b/tests/wake-helpers.sh index dd0277c1d8..344d186af2 100644 --- a/tests/wake-helpers.sh +++ b/tests/wake-helpers.sh @@ -93,7 +93,7 @@ SH # Install a hermetic fake fm-crew-state.sh into and echo its path. The # watcher's absorb-only-when-provably-working triage calls this (via -# FM_CREW_STATE_BIN) to read a crew's current state on no-verb signal and stale +# FM_CREW_STATE_BIN) to read a crew's current state on ambiguous signal and stale # paths; the fake returns a canned "state: · source: · " # verdict line so a test can fix the provably-working decision without a real # worktree or no-mistakes.