diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index ba7546c160..c224eb9793 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -136,8 +136,8 @@ The daemon still clears its buffer only on the backend's `empty` success verdict The daemon wraps `fm-watch.sh`, runs the watcher as a child, presents every durable wake after each actionable watcher close, classifies each presented record in bash, and acknowledges the presented generation only after routing completes. It self-handles the routine majority without consuming a firstmate turn. -Captain-relevant events, plus a bounded recheck of a declared external wait that remains idle, escalate to firstmate's context as one pre-read, single-line, batched digest. -The classification predicates (the captain-relevant verb set, declared-pause vocabulary, signal/stale tests, and fleet-scan) live in the shared `bin/fm-classify-lib.sh`, the same library the always-on watcher uses for its own triage when afk is off, so the two modes apply one identical policy. +Captain-relevant events, plus a bounded recheck of a declared wait that remains idle, escalate to firstmate's context as one pre-read, single-line, batched digest. +The classification predicates (the captain-relevant verb set, declared-wait vocabulary, signal/stale tests, and fleet-scan) live in the shared `bin/fm-classify-lib.sh`, the same library the always-on watcher uses for its own triage when afk is off, so the two modes apply one identical policy. While `state/.afk` exists the daemon owns the watcher, so the watcher reverts to one-shot and lets the daemon do the triage - the two never run their triage at the same time. Classify each wake this way: @@ -145,8 +145,9 @@ Classify each wake this way: - `signal` with a terminal captain verb (`done:`, `needs-decision:`, `blocked:`, or `failed:`) -> escalate. A nonterminal progress verb remains nonterminal even when its prose contains a legacy free-text token such as `PR ready`, `checks green`, `ready in branch`, or `merged`; only a bare legacy line with such a token escalates. Other signals with no captain-relevant status -> self-handle. -- `signal` or `stale` for a declared `paused:` external wait -> self-handle and track the pause rather than a wedge. - If it remains declared and idle past `FM_PAUSE_RESURFACE_SECS` (default 3600s), housekeeping sends one awaiting-external recheck and resets the pause window. +- `signal` or `stale` for a declared wait, either a `paused:` external wait or a verified `captain-held` transfer -> self-handle and track the pause rather than a wedge. + If it remains declared and idle past `FM_PAUSE_RESURFACE_SECS` (default 3600s), housekeeping sends one recheck and resets the pause window. + That recheck names which human the wait is on: the external dependency for `paused:`, and the captain themself for a `captain-held` transfer, who can answer the held decision or release the hold. - `check` -> always escalate. Check scripts print only when firstmate should wake. - `stale` with a terminal status or bare legacy captain-relevant line -> escalate. Nonterminal progress remains transient even when its prose contains a legacy free-text token or its seen-status marker already matches, so record a marker and self-handle. diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index d650112ddb..9915ece7d2 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -76,7 +76,7 @@ FM_CLASSIFY_CAPTAIN_RE_DEFAULT='done:|needs-decision:|blocked:|failed:|PR ready| # drift between the two consumers. FM_CLASSIFY_PAUSED_VERB overrides it. FM_CLASSIFY_PAUSED_VERB_DEFAULT='paused' -# Bounded re-surface cadence for a declared pause or a dead-agent captain hold. +# Bounded re-surface cadence for a declared pause or a verified captain hold. # Far longer than the wedge threshold (FM_STALE_ESCALATE_SECS, default 240s), it # avoids nagging a deliberate wait while ensuring a forgotten hold cannot rot # invisibly - it re-surfaces once for a recheck every window. One hour by default; @@ -146,19 +146,31 @@ status_is_paused() { # [ "$verb" = "${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}" ] } -# 0 if a status line declares either an external-wait pause or a verified -# captain-held transfer. -# Both declarations can intentionally leave an exited crew's endpoint idle, so -# the watcher applies its bounded pause cadence when agent death confirms that -# no live decision gate is being silenced. -status_is_paused_or_captain_held() { # +# 0 if a status line's leading verb is the verified captain-held transfer verb. +# The same pure verb read as status_is_paused, and the discriminator a supervisor +# needs once a declared wait has already been recognized: the two declarations get +# the same bounded cadence, but they block on DIFFERENT humans, so a recheck that +# names an external dependency for a hold points the captain away from the fact +# that they are the one who can clear it. +status_is_captain_held() { # local line=$1 verb - status_is_paused "$line" && return 0 [ -n "$line" ] || return 1 verb=$(status_line_verb "$line") [ "$verb" = "${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}" ] } +# 0 if a status line declares either an external-wait pause or a verified +# captain-held transfer. +# Both declarations can intentionally leave a crew's endpoint idle, so both +# supervisors give them one cadence: the away-mode daemon defers the wedge and +# ages a pause marker instead, and the watcher applies its bounded pause cadence +# once pause_state_class has admitted the wait (fm-watch.sh owns which liveness +# evidence each kind of crew must supply for that). +status_is_paused_or_captain_held() { # + local line=$1 + status_is_paused "$line" || status_is_captain_held "$line" +} + # --- durable keyed decisions ------------------------------------------------ # # The status stream is an append-only EVENT log. Reading it last-event-wins diff --git a/bin/fm-push-transition-lib.sh b/bin/fm-push-transition-lib.sh index 5ee55fd3b4..19d0a142a9 100644 --- a/bin/fm-push-transition-lib.sh +++ b/bin/fm-push-transition-lib.sh @@ -130,8 +130,12 @@ handle_push_transition() { # [ -n "$pane_id" ] || { sleep 1; return; } window="$session:$pane_id" task=$(window_to_task "$window" "$STATE") - if status_is_paused "$(last_status_line "$STATE/$task.status")"; then - triage_log "absorbed push $to (declared pause, awaiting external): $window" + # A declared wait already names the human this transition would report: an + # external dependency, or the captain a verified hold transferred the work to. + # Either way the wait is durably recorded, so absorb the immediate escalation + # and leave the bounded re-surface to the watcher's own pause cadence. + if status_is_paused_or_captain_held "$(last_status_line "$STATE/$task.status")"; then + triage_log "absorbed push $to (declared wait, awaiting external or captain): $window" fm_backend_commit_transition "$backend" "$STATE" "$session" "$record" || exit 1 return fi diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index c772bd3a15..86bad52b44 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -5,11 +5,11 @@ # durable wake after an actionable close, acknowledges only after routing, and # either SELF-HANDLES the routine majority in bash (no firstmate turn) or # ESCALATES a batched, distilled digest to the supervisor pane on -# captain-relevant events plus bounded declared-pause rechecks. This is the +# captain-relevant events plus bounded declared-wait rechecks. This is the # token-efficient replacement for the prior always-inject daemon: routine # signal/stale/heartbeat wakes cost zero firstmate context; only done/ # needs-decision/blocked/failed/persistent-wedge/check-output events and a -# declared-pause recheck reach the LLM, and even then as one pre-read digest per +# declared-wait recheck reach the LLM, and even then as one pre-read digest per # batch window. # # PRESENCE-GATING (the /afk contract). The daemon is the away-mode engine: it @@ -41,11 +41,13 @@ # drain and acknowledges it only after routing completes. # - Fail-safe-to-escalate: any wake the classifier cannot confidently mark # routine is escalated. -# - Bounded wedge latency: a stale pane without a declared external wait is -# escalated only after it has been idle for STALE_ESCALATE_SECS +# - Bounded wedge latency: a stale pane without a declared wait is escalated +# only after it has been idle for STALE_ESCALATE_SECS # (configurable), rechecked once. A wedged crewmate is therefore detected -# within STALE_ESCALATE_SECS + a tick, never lost. A declared pause instead -# gets its own longer PAUSE_RESURFACE_SECS recheck, never a wedge escalation. +# within STALE_ESCALATE_SECS + a tick, never lost. A declared wait - either a +# paused: external wait or a verified captain-held transfer, per +# fm-classify-lib.sh's combined predicate - instead gets its own longer +# PAUSE_RESURFACE_SECS recheck, never a wedge escalation. # Crewmates are autonomous, so a delayed stale response does not stall a # healthy crewmate's own progress. # Buffered escalation delivery also has a max-defer alarm: if a digest stays @@ -89,8 +91,9 @@ # kinds. # FM_STALE_ESCALATE_SECS idle seconds before a stale pane escalates # as a possible wedge (default 240) -# FM_PAUSE_RESURFACE_SECS idle seconds before a declared external wait -# re-surfaces as a recheck (default 3600) +# FM_PAUSE_RESURFACE_SECS idle seconds before a declared wait (external +# or captain-held) re-surfaces as a recheck +# (default 3600) # FM_ESCALATE_BATCH_SECS buffer window for batched escalation # digests; 0 = flush immediately (default 90) # FM_HEARTBEAT_SCAN_SECS cadence for the catch-all status scan @@ -372,12 +375,13 @@ classify_stale() { # local win=$1 state=$2 task last seen task=$(window_to_task "$win" "$state") last=$(last_status_line "$state/$task.status") - if [ -n "$last" ] && status_is_paused "$last"; then - # A DECLARED external-wait pause (fm-classify-lib.sh): an idle pane is EXPECTED, - # so this is not a wedge. The caller records a pause marker (long re-surface - # cadence in housekeeping) rather than a wedge stale marker. Cheap: reuses the - # status line already read, no fm-crew-state.sh call, mirroring the daemon's - # existing status-log classification. + if [ -n "$last" ] && status_is_paused_or_captain_held "$last"; then + # A DECLARED external-wait pause or a verified captain-held transfer + # (fm-classify-lib.sh owns which declarations qualify): an idle pane is + # EXPECTED, so this is not a wedge. The caller records a pause marker (long + # re-surface cadence in housekeeping) rather than a wedge stale marker. Cheap: + # reuses the status line already read, no fm-crew-state.sh call, mirroring the + # daemon's existing status-log classification. printf 'pause|paused (awaiting external), rechecked on a long cadence: %s' "$last" return fi @@ -446,10 +450,11 @@ stale_marker_remove() { # rm -f "$state/.subsuper-stale-$key" } -# Pause marker: state/.subsuper-paused- holds the epoch a declared pause was -# first observed idle. Housekeeping ages it against PAUSE_RESURFACE_SECS (much -# longer than a wedge) and re-surfaces the pause once per window. Recording is -# create-if-absent so the timestamp is stable across a churny idle pane (many +# Pause marker: state/.subsuper-paused- holds the epoch a declared wait (a +# paused: external wait or a verified captain-held transfer) was first observed +# idle. Housekeeping ages it against PAUSE_RESURFACE_SECS (much longer than a +# wedge) and re-surfaces the wait once per window. Recording is create-if-absent +# so the timestamp is stable across a churny idle pane (many # distinct stale hashes map to one marker), keeping the cadence hash-immune. pause_marker_record() { # - create if absent local win=$1 state=$2 key marker @@ -481,7 +486,7 @@ reconcile_pause_tracking() { # key=$(_stale_key "$task") marker="$state/.subsuper-paused-$key" watcher_key=$(_stale_key "$win") - if status_is_paused "$last"; then + if status_is_paused_or_captain_held "$last"; then stale_marker_remove "$win" "$state" pause_marker_record "$win" "$state" elif [ -e "$marker" ] || [ -e "$state/.paused-$watcher_key" ]; then @@ -499,7 +504,7 @@ migrate_watcher_pause_markers() { # key=$(_stale_key "$task") watcher_key=$(_stale_key "$win") last=$(last_status_line "$state/$task.status") - if status_is_paused "$last" || [ -e "$state/.subsuper-paused-$key" ] || [ -e "$state/.paused-$watcher_key" ]; then + if status_is_paused_or_captain_held "$last" || [ -e "$state/.subsuper-paused-$key" ] || [ -e "$state/.paused-$watcher_key" ]; then reconcile_pause_tracking "$win" "$state" "$last" fi done @@ -954,9 +959,10 @@ _oldest_line_age() { # -> seconds since the oldest buffered item first ar # Never silently defer forever. # 2) stale recheck: for each pending stale marker past STALE_ESCALATE_SECS, # re-peek the pane; still idle -> escalate (wedge); resumed -> clear marker. -# 2b) pause re-surface: for each declared-pause marker past PAUSE_RESURFACE_SECS, -# re-peek; busy/gone -> clear; still idle + still paused -> escalate a recheck -# digest and reset the window (repeating bounded re-surface, never a wedge). +# 2b) pause re-surface: for each declared-wait marker past PAUSE_RESURFACE_SECS, +# re-peek; busy/gone -> clear; still idle + still declaring the wait -> escalate +# a recheck digest naming which human the wait is on, and reset the window +# (repeating bounded re-surface, never a wedge). # 3) heartbeat scan: every HEARTBEAT_SCAN_SECS, grep state/*.status for a # captain-relevant line the per-wake classifier missed and escalate it. housekeeping() { # @@ -1007,7 +1013,7 @@ housekeeping() { # fi task=$(window_to_task "$win" "$state") last=$(last_status_line "$state/$task.status") - if [ -n "$last" ] && status_is_paused "$last"; then + if [ -n "$last" ] && status_is_paused_or_captain_held "$last"; then reconcile_pause_tracking "$win" "$state" "$last" continue fi @@ -1022,12 +1028,15 @@ housekeeping() { # esac done - # (2b) pause re-surface recheck. A DECLARED external-wait pause idles by design, - # so it is rechecked on a much longer cadence than a wedge (PAUSE_RESURFACE_SECS) - # and never escalated as one - but it MUST re-surface, so a forgotten pause cannot - # rot invisibly. Past the window: busy (resumed) or gone -> drop; still idle and - # still declaring the pause -> escalate a recheck digest and reset the marker so - # the window repeats. + # (2b) pause re-surface recheck. A declared wait idles by design (fm-classify-lib.sh's + # status_is_paused_or_captain_held owns which declarations qualify), so it is + # rechecked on a much longer cadence than a wedge (PAUSE_RESURFACE_SECS) and never + # escalated as one - but it MUST re-surface, so neither a forgotten pause nor a + # forgotten captain hold can rot invisibly. Past the window: busy (resumed) or gone + # -> drop; still idle and still declaring the wait -> escalate a recheck digest and + # reset the marker so the window repeats. The digest names WHICH human the wait is + # on, because the captain is the one reading it: an external dependency for a + # paused: declaration, and the captain themself for a verified hold transfer. pause_secs=${FM_PAUSE_RESURFACE_SECS:-$FM_PAUSE_RESURFACE_SECS_DEFAULT} for marker in "$state"/.subsuper-paused-*; do [ -e "$marker" ] || continue @@ -1038,7 +1047,7 @@ housekeeping() { # fi task=$(window_to_task "$win" "$state") last=$(last_status_line "$state/$task.status") - if [ -z "$last" ] || ! status_is_paused "$last"; then + if [ -z "$last" ] || ! status_is_paused_or_captain_held "$last"; then reconcile_pause_tracking "$win" "$state" "$last" continue fi @@ -1050,7 +1059,10 @@ housekeeping() { # 2) rm -f "$marker" ;; *) last=$(last_status_line "$state/$task.status") - if [ -n "$last" ] && status_is_paused "$last"; then + if [ -n "$last" ] && status_is_captain_held "$last"; then + escalate_add "$state" "captain-held ${age}s (awaiting the captain, answer the held decision or release the hold): $win" + _now > "$marker" + elif [ -n "$last" ] && status_is_paused "$last"; then escalate_add "$state" "paused ${age}s (awaiting external, recheck whether the wait still holds): $win" _now > "$marker" else @@ -1237,10 +1249,10 @@ handle_wake() { # [ "${FM_ESCALATE_BATCH_SECS:-$ESCALATE_BATCH_SECS_DEFAULT}" -le 0 ] && { escalate_flush "$state" || true; } ;; pause) - # Declared external-wait pause: record a pause marker (long re-surface - # cadence in housekeeping) and drop any wedge stale marker, so a pane that - # transitioned working->paused is not still wedge-aged. Only stale produces - # this action. + # Declared wait, an external-wait pause or a verified captain-held transfer: + # record a pause marker (long re-surface cadence in housekeeping) and drop any + # wedge stale marker, so a pane that transitioned working->declared-wait is not + # still wedge-aged. Only stale produces this action. if [ "$kind" = "stale" ]; then stale_marker_remove "$arg" "$state" pause_marker_record "$arg" "$state" diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index a056045dfd..d1d59d3ceb 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -6,9 +6,10 @@ # 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 -# 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. +# working signal is never silently swallowed. A declared wait, either a paused: +# external wait or a verified captain-held transfer, 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. # 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 @@ -19,9 +20,11 @@ # run-step or busy pane outranks even a captain-relevant log # line, since the crew's own log gets no new entry once # firstmate hands it to a no-mistakes validation. A declared -# 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: +# external-wait pause or verified captain-held transfer is +# absorbed instead with its own long re-surface cadence, +# never as a wedge, and that recheck reason names which +# human the wait is on. 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 # wedge threshold also surfaces, with an "escalation N" @@ -174,7 +177,9 @@ BUSY_TURN_MAX_SECS=${FM_BUSY_TURN_MAX_SECS:-3600} # A crew that declared a pause is idling on a known external wait, so its stale # pane is absorbed rather than wedge-escalated. # A captain-held or paused crew whose agent has confidently exited uses the same -# bounded cadence, while a live or ambiguously read agent still surfaces once. +# bounded cadence, while a live or ambiguously read agent still surfaces once; a +# secondmate earns the cadence on its declaration alone, because its endpoint +# liveness is deliberately never read (pause_state_class owns that split). # These cases re-surface once for a recheck every PAUSE_RESURFACE_SECS - far # longer than the wedge threshold, but finite so a forgotten hold cannot rot invisibly. PAUSE_RESURFACE_SECS=${FM_PAUSE_RESURFACE_SECS:-$FM_PAUSE_RESURFACE_SECS_DEFAULT} @@ -417,8 +422,14 @@ busy_turn_over_age() { # # timer would. The bounded re-surface itself is the shared resurface_absorbed # above, throttled by this window's own .paused-resurfaced- marker. Advances # the stale suppressor to and flags the key paused. +# +# The recheck names WHICH human the declared wait is on, because that is the whole +# point of a recheck the captain reads: an external dependency for paused:, and the +# captain themself for a verified hold. Only the captain-held verb takes the second +# wording; a caller that reached the bounded cadence off pause tracking alone, with +# no declaring verb left on the log, keeps the external-wait wording it always had. handle_paused_stale() { # - local win=$1 task=$2 h=$3 key statusf mtime age + local win=$1 task=$2 h=$3 key statusf mtime age detail reason key=$(window_key "$win") printf '%s' "$h" > "$STATE/.stale-$key" : > "$STATE/.paused-$key" @@ -428,9 +439,15 @@ handle_paused_stale() { # mtime=$(stat_mtime "$statusf") case "$mtime" in ''|*[!0-9]*) mtime=$(date +%s) ;; esac age=$(( $(date +%s) - mtime )) - resurface_absorbed "$win" "$STATE/.paused-resurfaced-$key" "$age" \ - "stale: $win (paused ${age}s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)" - triage_log "absorbed stale (paused, awaiting external, age ${age}s): $win" + if status_is_captain_held "$(last_status_line "$statusf")"; then + detail="captain-held, awaiting the captain" + reason="captain-held ${age}s, awaiting the captain - verified hold transfer, rechecked on a long cadence not a wedge; answer the held decision or release the hold" + else + detail="paused, awaiting external" + reason="paused ${age}s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds" + fi + resurface_absorbed "$win" "$STATE/.paused-resurfaced-$key" "$age" "stale: $win ($reason)" + triage_log "absorbed stale ($detail, age ${age}s): $win" } # Apply the busy-pane completed-turn bound to a window whose bound has already @@ -470,10 +487,11 @@ clear_pause_tracking() { # } # Reconcile a declared pause or captain-held status with authoritative crew state. -# Only a confidently dead ordinary crew may recover paused classification after -# fm-crew-state has fallen back to stopped or unknown. +# After fm-crew-state has fallen back to stopped or unknown, paused classification is +# recovered only for a confidently dead ordinary crew, or for a secondmate, whose +# endpoint liveness this function deliberately never reads. pause_state_class() { # - local win=$1 task=$2 key last recheck_file class agent_alive + local win=$1 task=$2 key last recheck_file class agent_alive kind key=$(window_key "$win") last=$(last_status_line "$STATE/$task.status") recheck_file="$STATE/.paused-rechecked-$key" @@ -482,8 +500,12 @@ pause_state_class() { # crew_absorb_class "$task" return fi + # Read once past the declared-wait gate and reused by both liveness gates below, + # so a mate's stale poll costs one metadata scan rather than one per gate, and the + # far more common no-declaration path above still costs none. + kind=$(window_kind "$win") if [ -e "$STATE/.paused-$key" ] && [ "$(age_of "$recheck_file")" -lt "$STALE_ESCALATE_SECS" ]; then - if [ "$(window_kind "$win")" != secondmate ]; then + if [ "$kind" != secondmate ]; then agent_alive=$(fm_backend_agent_alive "$(window_backend "$win")" "$win" 2>/dev/null) || agent_alive=unknown if [ "$agent_alive" != dead ]; then rm -f "$recheck_file" @@ -500,7 +522,7 @@ pause_state_class() { # printf 'working' return fi - if [ "$(window_kind "$win")" != secondmate ]; then + if [ "$kind" != secondmate ]; then agent_alive=$(fm_backend_agent_alive "$(window_backend "$win")" "$win" 2>/dev/null) || agent_alive=unknown if [ "$agent_alive" != dead ]; then rm -f "$recheck_file" @@ -508,7 +530,15 @@ pause_state_class() { # return fi fi - [ "$class" = none ] && [ "${agent_alive:-unknown}" = dead ] && class=paused + # Recover paused classification for a declared wait that authoritative crew state + # could not name. Reaching here already proves the only two admissible cases: an + # ordinary crew whose agent the gate above confirmed dead, so no live decision gate + # is being silenced, or a secondmate, whose endpoint liveness is deliberately never + # read and so cannot supply that confirmation. Without the mate case a mate's + # captain hold - which has no current-state mapping and so arrives as `none` - + # would be silenced by every caller rather than taking the bounded re-surface + # cadence, and a forgotten hold would rot invisibly. + [ "$class" = none ] && class=paused case "$class" in paused) date +%s > "$recheck_file" ;; *) rm -f "$recheck_file" ;; @@ -1106,7 +1136,14 @@ EOF if ! status_is_paused_or_captain_held "$last" && [ -e "$STATE/.paused-$key" ]; then clear_pause_tracking "$key" fi - if [ "$kind" = secondmate ] && ! status_is_paused "$last"; then + # An idle secondmate endpoint is healthy by design, so a mate is admitted to + # the pane-stale path ONLY to serve a declared wait's bounded re-surface - + # the same declarations pause_state_class reconciles below, which is why this + # gate reads the shared predicate rather than the pause verb alone. Narrowing + # it to `paused` would leave a mate's captain hold rotting invisibly: the + # clear above already spares its pause tracking, but nothing would ever + # re-surface it. + if [ "$kind" = secondmate ] && ! status_is_paused_or_captain_held "$last"; then continue fi tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue @@ -1188,10 +1225,10 @@ EOF # - working: an actively-running pipeline legitimately sits on a static # pane (e.g. waiting on CI), so absorb and start the wedge timer so a # genuinely frozen run still escalates past STALE_ESCALATE_SECS; - # - paused: the crew declared an external wait, or a declared pause or - # captain hold is paired with a confidently dead agent, so absorb on - # the long PAUSE_RESURFACE_SECS cadence instead of wedge-escalating; - # - none: no running pipeline, no exact busy verdict, no declared pause. + # - paused: a declared wait pause_state_class admits (its header owns which + # liveness evidence each kind of crew must supply), so absorb on the long + # PAUSE_RESURFACE_SECS cadence instead of wedge-escalating; + # - none: no running pipeline, no exact busy verdict, no admitted declared wait. # Surface immediately so firstmate inspects the inconclusive state # (it may be done via an interactive menu that wrote no done: status, # waiting on a decision, or wedged) instead of leaving the finish to diff --git a/docs/architecture.md b/docs/architecture.md index bfcd2dc0e8..04e1d51eb2 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 a Relay 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` without their own task worktree being written, declared external waits that remain paused past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits. +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 a Relay 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` without their own task worktree being written, declared external waits and verified captain-held transfers that remain declared 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 pane holding a file newer than the start of its own quiet window, anywhere in the worktree recorded for that task, is deferred instead of escalated, because a crew writing source, then tests, then documentation behind a static pane is liveness that neither pane quietness nor the run step can show. That deferral re-surfaces on the same `FM_PAUSE_RESURFACE_SECS` cadence as a declared wait, with a reason naming the write evidence rather than a wedge, and it is bounded to one pruned, depth-bounded, wall-clock-bounded walk (`FM_WORKTREE_WRITE_PRUNE`, `FM_WORKTREE_WRITE_MAXDEPTH`, `FM_WORKTREE_WRITE_TIMEOUT`) taken only in the branch that was about to escalate, never on every poll. @@ -25,9 +25,9 @@ A concurrent replacement remains armed, every non-merged or invalid observation `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. A `kind=secondmate` task's status signal is the parent-directed reply stream and is never absorbed as provably working; only its bare turn-ended signal retains the ordinary absorb rule. -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. +A crew that declares `paused:` for a known external wait, or carries a verified `captain-held` transfer, 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. +Live or inconclusive liveness remains fail-open at that initial surface, and a secondmate's endpoint liveness is still never read at all; a mate is admitted to that same cadence only to serve a declared wait's bounded re-surface, so a forgotten pause or captain hold on a mate cannot rot invisibly. 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. 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. @@ -37,7 +37,7 @@ A secondmate retains a durable receipt for its idempotent report through the est Absorbed wakes advance their suppression markers, log to `state/.watch-triage.log`, and keep the watcher blocking without a queue record or LLM turn. Each `fm-wake-drain.sh` presentation runs the same liveness guard as the supervision scripts, so a lapsed watcher chain surfaces even on a turn that only handles queued wakes. Routine watcher polling, supervision no-ops, elapsed waiting time, and absorbed benign wakes stay silent. -A declared external wait trades that silence for one bounded recheck per pause window, so a forgotten pause cannot remain invisible indefinitely. +A declared external wait or verified captain-held transfer trades that silence for one bounded recheck per pause window, naming which human the wait is on, so neither a forgotten pause nor a forgotten hold can remain invisible indefinitely. Crew status files are append-only wake-event logs, not current-state fields. Because of that, a per-wake read of only the latest line can bury an earlier still-open `needs-decision`/`blocked` under later unrelated appends; `fm-wake-drain.sh` prints a separate, fleet-wide OPEN DECISIONS section on every presentation (including the empty-queue path session-start relies on), built through `fm-classify-lib.sh`'s cursor-backed incremental scan using the authoritative `status_open_decisions` fold semantics so the buried decision keeps surfacing until it is explicitly resolved while each presentation folds only new status-log appends. The drain coordinates that fold and its annotations through a locked fleet-wide snapshot whose `.status-presentation-cursor` manifest records each status file's identity and last-presented byte offset. @@ -92,12 +92,12 @@ On every verified primary harness, tracked hook integration gives the primary se The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). 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. +The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-wait vocabulary (a `paused:` external wait and a verified `captain-held` transfer alike, through one combined predicate), 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 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-wait 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. Away-mode housekeeping has no worktree-write deferral of its own, so while `state/.afk` exists a quiet crew that is writing its own worktree still escalates as a possible wedge at that 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. +The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause or a verified captain-held transfer that remains idle, naming which human that wait is on, 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. Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native agent-state submit confirmation on idle baselines, a composer empty fallback when native stays idle, and a pre-Enter rendered-footer transition when that baseline is unavailable. The retries-exhausted queued-Enter decision is owned by `fm_composer_queued_enter_verdict` in `bin/fm-composer-lib.sh`; tmux and herdr provide only their backend-specific busy signals. diff --git a/docs/configuration.md b/docs/configuration.md index 277e925e7c..5747a10a0a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -589,7 +589,7 @@ FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|read FM_CLASSIFY_PAUSED_VERB=paused # leading status verb for a declared external wait; excluded from FM_CAPTAIN_RE and distinct from blocked FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates; stale panes whose crew is not provably working surface immediately unless they declare the pause verb FM_BUSY_TURN_MAX_SECS=3600 # maximum age of a busy pane's latest state/.turn-ended marker, or its state/.meta spawn record before any turn completes, before the same wedge escalation used for a provably-working non-busy stale takes over; inspection-only, never an automatic interrupt or restart; a declared external wait or verified captain-held transfer takes the FM_PAUSE_RESURFACE_SECS recheck below instead -FM_PAUSE_RESURFACE_SECS=3600 # seconds before the watcher re-surfaces a declared external wait or verified captain-held transfer for a recheck, including a live busy pane past FM_BUSY_TURN_MAX_SECS; the away-mode daemon uses the same setting for declared external waits +FM_PAUSE_RESURFACE_SECS=3600 # seconds before the watcher re-surfaces a declared external wait or verified captain-held transfer for a recheck, including a live busy pane past FM_BUSY_TURN_MAX_SECS; the away-mode daemon uses the same setting for a declared external wait or verified captain-held transfer FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added FM_WORKTREE_WRITE_PRUNE='.git node_modules .venv venv __pycache__ .mypy_cache .pytest_cache .ruff_cache .tox target dist build .next .cache vendor' # directory names the wedge detector's task-worktree write probe skips; the default keeps .git out so a supervisor's own read-only git command can never look like crew progress; set it to the empty string to prune nothing, which widens the probe to the whole depth-bounded tree rather than disabling it FM_WORKTREE_WRITE_MAXDEPTH=6 # depth that same probe walks below the recorded worktree; it runs only at the moment a wedge escalation would otherwise fire, never on every poll; no probe knob applies to a secondmate, whose recorded worktree is a provisioned home the probe skips entirely diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 22f9d967aa..029726f8be 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -275,7 +275,7 @@ Mid-session secondmate liveness is not implemented because idle secondmates are Protocol 16 can subscribe to `pane.agent_status_changed` over one bounded Unix-socket reader. `bin/fm-transition-lib.sh` owns the backend-neutral transition vocabulary and policy. The Herdr adapter subscribes before reconciling current levels, buffers edges during reconciliation, and returns fresh blocked transitions for this home's panes. -The watcher maps the pane back to the task and skips secondmate endpoints and declared `paused:` waits. +The watcher maps the pane back to the task and skips secondmate endpoints, declared `paused:` waits, and verified `captain-held` transfers, because a declared wait already names the human the fast escalation would report and is left to the watcher's own bounded pause cadence. The push path only shortens latency. Polling runs every cycle and remains the permanent fallback when protocol 16, the event schema, Python, connection, subscription, or repeated reader execution is unavailable. diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index ad0925be4d..f2b5201e4c 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -220,6 +220,21 @@ test_stale_paused_classifies_pause() { pass "paused reasons with captain phrases remain pause-classified" } +# A verified captain-held transfer is the other declaration that leaves an idle pane +# EXPECTED, so it earns the same pause action as paused: rather than being aged as a +# wedge. The wait itself is already durable in the captain-held backlog task. +test_stale_captain_held_classifies_pause() { + local dir state out held_reason + dir=$(make_supercase stale-captain-held) + state="$dir/state" + held_reason='captain-held [key=route]: tracked by task-decision-route' + status_is_captain_relevant "$held_reason" && fail "a captain-held transfer line was treated as captain-relevant" + printf '%s\n' "$held_reason" > "$state/held-w9h.status" + out=$(FM_STATE_OVERRIDE="$state" classify_stale "sess:fm-held-w9h" "$state") + case "$out" in pause\|*) ;; *) fail "captain-held transfer did not classify as pause: $out" ;; esac + pass "a captain-held transfer classifies as pause, not as a wedge candidate" +} + # handle_wake on a paused stale records a pause marker, drops any pre-existing wedge # marker (so a working->paused pane is not still wedge-aged), and does NOT escalate # on the wake itself - the recheck is housekeeping's job on the long cadence. @@ -342,6 +357,7 @@ test_housekeeping_paused_resurfaces_and_resets() { PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state" grep -F "awaiting external" "$state/.subsuper-escalations" >/dev/null 2>&1 || fail "declared pause was not re-surfaced as an awaiting-external recheck" + grep -F "awaiting the captain" "$state/.subsuper-escalations" >/dev/null 2>&1 && fail "declared pause named the captain instead of its external dependency" grep -F "possible wedge" "$state/.subsuper-escalations" >/dev/null 2>&1 && fail "declared pause was mislabeled a possible wedge" [ -e "$state/.subsuper-paused-$key" ] || fail "pause marker cleared instead of reset for the next window" age=$(( $(date +%s) - $(cat "$state/.subsuper-paused-$key" 2>/dev/null || echo 0) )) @@ -349,6 +365,33 @@ test_housekeeping_paused_resurfaces_and_resets() { pass "housekeeping re-surfaces a stale declared pause on the long cadence and resets its window" } +# The other half of quieting a captain-held task: it must NOT be silenced outright. +# fm-classify-lib.sh's cadence comment is explicit that a forgotten hold cannot rot +# invisibly, so a held task re-surfaces on the same bounded window as a pause, with +# its marker reset so the window repeats instead of firing once. The digest the +# captain reads must also name the captain rather than an external dependency: the +# hold is waiting on the one person reading the digest, so borrowing the pause verb's +# awaiting-external wording would point them away from being the blocker. +test_housekeeping_captain_held_resurfaces_and_resets() { + local dir state fakebin win pane key age + dir=$(make_supercase captain-held-resurface) + state="$dir/state"; fakebin="$dir/fakebin" + win="sess:fm-held-w11h"; pane="$dir/pane.txt" + printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$state/held-w11h.status" + printf 'idle prompt $\n' > "$pane" + key=$(printf '%s' "held-w11h" | tr ':/.' '___') + echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ + FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state" + grep -F "awaiting the captain" "$state/.subsuper-escalations" >/dev/null 2>&1 || fail "a captain hold was silenced entirely instead of re-surfacing as a captain-owned recheck: $(cat "$state/.subsuper-escalations" 2>/dev/null || true)" + grep -F "awaiting external" "$state/.subsuper-escalations" >/dev/null 2>&1 && fail "a captain hold was re-surfaced as an external wait, hiding that the captain is the blocker" + grep -F "possible wedge" "$state/.subsuper-escalations" >/dev/null 2>&1 && fail "a captain hold was re-surfaced as a possible wedge" + [ -e "$state/.subsuper-paused-$key" ] || fail "captain-held marker cleared instead of reset for the next window" + age=$(( $(date +%s) - $(cat "$state/.subsuper-paused-$key" 2>/dev/null || echo 0) )) + [ "$age" -lt 60 ] || fail "captain-held marker was not reset to now on re-surface (age ${age}s)" + pass "housekeeping re-surfaces a forgotten captain hold on the long cadence and resets its window" +} + # A pause whose pane became busy again (the crew resumed) drops its marker without # escalating, exactly like a resumed wedge. test_housekeeping_paused_resumed_cleared() { @@ -390,6 +433,25 @@ test_housekeeping_paused_unpaused_cleared() { pass "housekeeping clears a paused marker once the crew is no longer declaring the pause" } +# Once the captain answers, the hold is no longer a declared wait: the resolved line +# takes over the last-line read, so the pause cadence must stop claiming the task +# rather than keep re-surfacing a settled decision. +test_housekeeping_captain_held_resolved_cleared() { + local dir state fakebin win pane key + dir=$(make_supercase captain-held-resolved) + state="$dir/state"; fakebin="$dir/fakebin" + win="sess:fm-held-w13h"; pane="$dir/pane.txt" + printf 'captain-held [key=route]: tracked by task-decision-route\nresolved [key=route]: captain chose the direct path\n' > "$state/held-w13h.status" + printf 'idle prompt $\n' > "$pane" + key=$(printf '%s' "held-w13h" | tr ':/.' '___') + echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ + FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state" + [ -e "$state/.subsuper-paused-$key" ] && fail "an answered captain hold kept its pause marker" + [ ! -s "$state/.subsuper-escalations" ] || fail "an answered captain hold was re-surfaced as a declared wait" + pass "housekeeping clears the pause marker once a captain hold is answered" +} + test_housekeeping_stale_marker_transitions_to_pause() { local dir state fakebin win pane key dir=$(make_supercase stale-to-paused) @@ -406,6 +468,25 @@ test_housekeeping_stale_marker_transitions_to_pause() { pass "housekeeping moves an existing stale marker to pause before wedge escalation" } +# The quieting half for a captain hold. A finished task marked captain-held is idle by +# design, so an already-aged wedge marker converts to pause tracking on the next sweep +# instead of firing the possible-wedge escalation. +test_housekeeping_captain_held_stale_marker_transitions_to_pause() { + local dir state fakebin win pane key + dir=$(make_supercase stale-to-captain-held) + state="$dir/state"; fakebin="$dir/fakebin"; win="sess:fm-held-w14h"; pane="$dir/pane.txt" + printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$state/held-w14h.status" + printf 'idle prompt $\n' > "$pane" + key=$(printf '%s' "held-w14h" | tr ':/.' '___') + echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-stale-$key" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ + FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" + [ -e "$state/.subsuper-paused-$key" ] || fail "a captain hold did not move its stale marker to pause tracking" + [ ! -e "$state/.subsuper-stale-$key" ] || fail "a captain hold remained wedge-aged" + [ ! -s "$state/.subsuper-escalations" ] || fail "a captain hold was escalated as a possible wedge" + pass "housekeeping moves a captain hold's existing stale marker to pause before wedge escalation" +} + test_housekeeping_pause_marker_transitions_to_clear() { local dir state fakebin win pane key dir=$(make_supercase paused-to-stale) @@ -1853,6 +1934,7 @@ test_stale_transient_self_records_marker test_stale_diagnostic_wedge_survives_busy_housekeeping test_stale_terminal_escalates test_stale_paused_classifies_pause +test_stale_captain_held_classifies_pause test_handle_wake_paused_records_pause_marker test_handle_wake_paused_signal_records_pause_marker test_handle_wake_terminal_signal_clears_pause_tracking @@ -1862,9 +1944,12 @@ test_housekeeping_seeds_pause_marker_from_status test_housekeeping_persistent_stale_escalates test_housekeeping_resumed_stale_cleared test_housekeeping_paused_resurfaces_and_resets +test_housekeeping_captain_held_resurfaces_and_resets test_housekeeping_paused_resumed_cleared test_housekeeping_paused_unpaused_cleared +test_housekeeping_captain_held_resolved_cleared test_housekeeping_stale_marker_transitions_to_pause +test_housekeeping_captain_held_stale_marker_transitions_to_pause test_housekeeping_pause_marker_transitions_to_clear test_housekeeping_herdr_persistent_stale_resolves_meta test_housekeeping_herdr_idle_busy_record_clears_stale diff --git a/tests/fm-supervision-events.test.sh b/tests/fm-supervision-events.test.sh index ca6c683907..57bd63d4b9 100755 --- a/tests/fm-supervision-events.test.sh +++ b/tests/fm-supervision-events.test.sh @@ -82,6 +82,19 @@ fi grep -q 'absorbed push' "$STATE_DIR/.watch-triage.log" 2>/dev/null || fail "the paused absorb should be logged to the triage log" pass "handle_push_transition: a declared-pause crew is absorbed (no fast wake), left to the poll loop's long cadence" +# --- handle_push_transition: absorb for a verified captain-held transfer ------- + +reset_state +fm_write_meta "$STATE_DIR/tk2h.meta" "window=default:wG:pQ" "backend=herdr" "kind=ship" +printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$STATE_DIR/tk2h.status" +handle_push_transition herdr default "$(mkrec wG:pQ blocked)" +if [ -e "$STATE_DIR/.wake-queue" ] && grep -q 'stale' "$STATE_DIR/.wake-queue"; then + fail "a captain-held crew must NOT be fast-escalated: $(cat "$STATE_DIR/.wake-queue")" +fi +[ ! -s "$WAKE_LOG" ] || fail "a captain-held crew must not wake the supervisor from the event fast-path" +grep -q 'absorbed push' "$STATE_DIR/.watch-triage.log" 2>/dev/null || fail "the captain-held absorb should be logged to the triage log" +pass "handle_push_transition: a captain-held crew is absorbed (no fast wake), left to the poll loop's long cadence" + # --- event_wait_or_sleep: secondmate windows are excluded from the pane list -- reset_state diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 569d8a50b4..149cf30952 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -337,7 +337,17 @@ test_status_is_paused_classifier() { || fail "captain-held transfer not recognized by the bounded-idle classifier" status_is_paused_or_captain_held 'resolved [key=route]: captain answered' \ && fail "resolved decision remained classed as captain-held" - pass "status_is_paused: only the leading paused verb matches, and paused is not captain-relevant" + # The two declarations share one cadence but block on different humans, so the + # combined predicate cannot be the only discriminator: a recheck has to know which + # verb it is naming. + status_is_captain_held 'captain-held [key=route]: tracked by task-decision-route' \ + || fail "captain-held verb not recognized" + status_is_captain_held 'paused: holding for the upstream release' \ + && fail "a declared pause matched the captain-held verb" + status_is_captain_held 'working: the captain-held backlog item is next' \ + && fail "a working line mentioning captain-held false-matched" + status_is_captain_held '' && fail "empty line classified as captain-held" + pass "status_is_paused: only the leading paused verb matches, paused is not captain-relevant, and the two declared-wait verbs stay separable" } # crew_absorb_class: the single fm-crew-state.sh read that returns BOTH absorb @@ -1051,8 +1061,10 @@ test_exited_declared_pause_is_bounded_but_live_gate_surfaces() { FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & pid=$! wait_for_exit "$pid" 100 || fail "captain-held dead-agent pane did not re-surface on the bounded cadence" + grep -F "awaiting the captain" "$state/.wake-queue" >/dev/null \ + || fail "captain-held dead-agent pane surfaced as a stopped crew instead of a captain-owned recheck: $(cat "$state/.wake-queue")" grep -F "awaiting external" "$state/.wake-queue" >/dev/null \ - || fail "captain-held dead-agent pane surfaced as a stopped crew" + && fail "captain-held dead-agent pane borrowed the pause verb's external-wait wording" dir=$(make_case alive-decision-gate); state="$dir/state"; fakebin="$dir/fakebin" out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/gate.status" @@ -1124,11 +1136,46 @@ test_secondmate_paused_resurfaces_in_normal_mode() { wait_for_exit "$pid" 100 || fail "watcher did not re-surface a paused secondmate" grep -F "stale: $window" "$out" >/dev/null || fail "paused secondmate did not emit a stale recheck" grep -F "awaiting external" "$out" >/dev/null || fail "paused secondmate recheck omitted its external-wait reason" + grep -F "awaiting the captain" "$out" >/dev/null && fail "paused secondmate recheck named the captain instead of its external dependency" grep -F "possible wedge" "$out" >/dev/null && fail "paused secondmate was mislabeled a wedge" unset FM_FAKE_CREW_STATE pass "a declared paused secondmate re-surfaces on the bounded normal-mode cadence" } +# A captain hold is the other declared wait, but unlike paused: it has no +# current-state mapping, so a held mate reports `unknown` rather than `paused`. +# The bounded re-surface must still reach it, or a mate's hold rots invisibly: +# nothing else re-reads a quiet mate's endpoint. +test_secondmate_captain_held_resurfaces_in_normal_mode() { + local dir state fakebin out capture_file statusf window key pane_hash sig pid back + dir=$(make_case secondmate-held-resurface); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/secondmate-hold.status" + window="test:fm-secondmate-hold" + printf 'idle awaiting the captain\n' > "$capture_file" + printf 'window=%s\nkind=secondmate\n' "$window" > "$state/secondmate-hold.meta" + printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$statusf" + back=$(( $(date +%s) - 500 )) + if [ "$(uname)" = Darwin ]; then touch -mt "$(date -r "$back" '+%Y%m%d%H%M.%S')" "$statusf" + else touch -m -d "@$back" "$statusf"; fi + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-secondmate-hold_status" + key=$(printf '%s' "$window" | tr '.:/' '___') + pane_hash=$(hash_text "idle awaiting the captain") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: unknown · source: none · no current-state source available' + 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_PAUSE_RESURFACE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not re-surface a captain-held secondmate" + grep -F "stale: $window" "$out" >/dev/null || fail "captain-held secondmate did not emit a stale recheck" + grep -F "awaiting the captain" "$out" >/dev/null || fail "captain-held secondmate recheck did not name the captain as the blocker: $(cat "$out")" + grep -F "awaiting external" "$out" >/dev/null && fail "captain-held secondmate recheck claimed an external wait" + grep -F "possible wedge" "$out" >/dev/null && fail "captain-held secondmate was mislabeled a wedge" + unset FM_FAKE_CREW_STATE + pass "a captain-held secondmate re-surfaces on the bounded normal-mode cadence" +} + test_secondmate_nonpaused_stale_remains_suppressed() { local dir state fakebin out capture_file statusf window key pane_hash sig pid dir=$(make_case secondmate-stale-suppressed); state="$dir/state"; fakebin="$dir/fakebin" @@ -2590,6 +2637,7 @@ test_nonterminal_stale_not_working_surfaced test_nonterminal_stale_paused_absorbed_then_resurfaced test_exited_declared_pause_is_bounded_but_live_gate_surfaces test_secondmate_paused_resurfaces_in_normal_mode +test_secondmate_captain_held_resurfaces_in_normal_mode test_secondmate_nonpaused_stale_remains_suppressed test_secondmate_unpause_clears_pause_tracking test_nonterminal_stale_pause_transitions_reclassify_unchanged_hash