Skip to content

fix(bin): keep the long pause cadence for a live declared-pause agent - #2419

Open
Kallas95 wants to merge 3 commits into
kunchenguid:mainfrom
Kallas95:fm/fm-paused-live-agent
Open

fix(bin): keep the long pause cadence for a live declared-pause agent#2419
Kallas95 wants to merge 3 commits into
kunchenguid:mainfrom
Kallas95:fm/fm-paused-live-agent

Conversation

@Kallas95

Copy link
Copy Markdown

Intent

Fix: a declared paused: status is ignored by supervision while the worker is still alive, so a legitimate long external wait raises repeated stale alerts. Expected: when a task's last classified status event is a declared paused: (verb FM_CLASSIFY_PAUSED_VERB, default paused), the watcher applies the long bounded recheck cadence even if the pane/agent is alive - exactly the semantics already served to exited agents. Safety bounds (must not weaken real-blockage detection): the long cadence applies only to an otherwise healthy and idle pane; any event after paused: (new status line, turn-end, activity) returns to normal cadence; a paused: must never mask an agent that subsequently dies - endpoint-death detection keeps priority over the long cadence; do not touch blocked: semantics or wedge escalations for panes without a declared pause. Established the exact owners in bin/fm-watch.sh (pause_state_class) and bin/fm-classify-lib.sh (status_is_paused). Delivery: this repo's known constraint - origin kunchenguid/firstmate has no write access, machine firewall refuses push to master/main and refuses git rebase; push only branch fm/fm-paused-live-agent to the fork https://github.com/Kallas95/firstmate by explicit URL (worktree remotes unchanged), open the upstream PR against kunchenguid/firstmate:main. The upstream repo runs no CI on fork PRs: local green validation + upstream PR open is the terminal state; close the CI wait by the pipeline's supported cancel path without waiting for checks that will never arrive.

What Changed

  • pause_state_class in bin/fm-watch.sh now guards both live-agent short-circuits with status_is_paused, so a task whose last classified status is a declared paused: stays on the bounded PAUSE_RESURFACE_SECS recheck instead of dropping back to the normal cadence and re-escalating every STALE_ESCALATE_SECS while the worker is alive. A live agent at a captain-held (non-pause) gate still surfaces once, and a working crew state or a non-pause status line still returns the pane to the normal cadence.
  • tests/fm-watch-triage.test.sh: reworked the live-gate case into test_exited_declared_pause_is_bounded_but_live_gate_absorbed, which now asserts the live declared pause absorbs (no wake printed or enqueued, pause marker recorded, wedge timer never started) and keeps that cadence on re-arm past the wedge threshold. Added test_live_paused_agent_death_still_rechecks, which absorbs a live pause, then kills the agent and asserts the pane re-surfaces as a paused recheck instead of staying hidden behind the pause or being labeled a possible wedge.
  • docs/architecture.md and the PAUSE_RESURFACE_SECS / pause_state_class comments describe the split: declared pauses use the bounded cadence whether the agent is alive or exited, while a captain-held endpoint joins it only when the backend confidently reports the agent dead.

Risk Assessment

✅ Low: Correctif à deux gardes strictement délimité par status_is_paused au point d'ancrage désigné par l'intention (pause_state_class), dont chaque borne de sécurité requise (cadence longue seulement sur pane inactif, retour à la cadence normale sur tout événement, mort non masquée via recheck borné, sémantique blocked:/wedge intacte) est vérifiée dans le source et exercée par des tests comportementaux pilotant un vrai sous-processus fm-watch.sh.

Testing

J'ai exercé la suite propriétaire du watcher (tests/fm-watch-triage.test.sh, 50 ok / 0 échec) puis, parce que des tests verts ne prouvent pas à eux seuls l'expérience utilisateur, j'ai construit un scénario de supervision end-to-end qui pilote le véritable bin/fm-watch.sh sur la fixture tmux/fm-crew-state hermétique du dépôt et rejoue ce que vit un captain pendant une longue attente externe légitime : le même scénario exécuté contre le binaire du commit de base réveille le captain 6 fois sur 6 avec stale: fleet:fm-vendor-quota, contre le commit cible 0 fois sur 6, le watcher inscrivant à la place ses décisions absorbed stale (paused, awaiting external, age Ns) dans son journal de triage — la régression est donc reproduite avant le correctif et absente après. J'ai ensuite vérifié end-to-end les quatre bornes de sécurité de l'intent (sémantique blocked: et escalade wedge inchangées et identiques au commit de base, mort d'agent après pause toujours détectée et jamais requalifiée en wedge, retour à la cadence normale au premier nouvel événement de statut, verbe FM_CLASSIFY_PAUSED_VERB configurable honoré). Aucun échec, aucun test instable, aucun problème d'environnement à corriger. Ce changement ne touche aucune surface visuelle : fm-watch.sh est un superviseur shell dont la sortie utilisateur est un flux de raisons de réveil en terminal, donc les preuves sont des transcriptions CLI et le journal de triage persisté plutôt que des captures d'écran. À noter, un hook de pare-feu local bloque bash tests/<fichier> en analysant le contenu du script (faux positif sur un rm -f "$err" interne à la suite) ; la suite a néanmoins été exécutée complètement une fois, et la preuve rouge/vert contre le code de base a été obtenue au niveau produit via les transcriptions A/B. L'arbre de travail est propre : le répertoire temporaire .evidence-baseline/ créé pendant les tests a été supprimé.

Evidence: Comparaison avant/après : ce que le captain voit pendant une pause déclarée sur agent vivant

A declared paused: while the agent is STILL ALIVE - what the captain actually sees Same scenario, same fixture, same fake backend. Only bin/fm-watch.sh differs. Task fleet:fm-vendor-quota, last status event: paused: waiting on the upstream vendor to approve the API quota bump Agent alive (pane_current_command=grok), pane healthy and idle 500s (> 240s wedge). look | BEFORE (base f1a4af4) | AFTER (target 48df450) -----+---------------------------------------+---------------------------------------- 1 | ALERT: stale: fleet:fm-vendor-quota | quiet - not interrupted 2 | ALERT: stale: fleet:fm-vendor-quota | quiet - not interrupted 3 | ALERT: stale: fleet:fm-vendor-quota | quiet - not interrupted 4 | ALERT: stale: fleet:fm-vendor-quota | quiet - not interrupted 5 | ALERT: stale: fleet:fm-vendor-quota | quiet - not interrupted 6 | ALERT: stale: fleet:fm-vendor-quota | quiet - not interrupted -----+---------------------------------------+---------------------------------------- sum | 6 stale wakes delivered | 0 stale wakes delivered log | triage log empty (nothing absorbed) | 8 x absorbed on the pause cadence Absorb lines the fixed watcher wrote to .watch-triage.log: [2026-08-15T08:48:11+0200] absorbed stale (paused, awaiting external, age 22s): fleet:fm-vendor-quota [2026-08-15T08:48:12+0200] absorbed stale (paused, awaiting external, age 23s): fleet:fm-vendor-quota [2026-08-15T08:48:14+0200] absorbed stale (paused, awaiting external, age 25s): fleet:fm-vendor-quota

===========================================================================================
 A declared  paused:  while the agent is STILL ALIVE - what the captain actually sees
 Same scenario, same fixture, same fake backend. Only bin/fm-watch.sh differs.
 Task fleet:fm-vendor-quota, last status event:
   paused: waiting on the upstream vendor to approve the API quota bump
 Agent alive (pane_current_command=grok), pane healthy and idle 500s (> 240s wedge).
===========================================================================================

  look | BEFORE  (base f1a4af4)                | AFTER  (target 48df450)
  -----+---------------------------------------+----------------------------------------
  1    | ALERT: stale: fleet:fm-vendor-quota   | quiet - not interrupted
  2    | ALERT: stale: fleet:fm-vendor-quota   | quiet - not interrupted
  3    | ALERT: stale: fleet:fm-vendor-quota   | quiet - not interrupted
  4    | ALERT: stale: fleet:fm-vendor-quota   | quiet - not interrupted
  5    | ALERT: stale: fleet:fm-vendor-quota   | quiet - not interrupted
  6    | ALERT: stale: fleet:fm-vendor-quota   | quiet - not interrupted
  -----+---------------------------------------+----------------------------------------
  sum  | 6 stale wakes delivered               | 0 stale wakes delivered
  log  | triage log empty (nothing absorbed)   | 8 x absorbed on the pause cadence

Absorb lines the fixed watcher wrote to .watch-triage.log:
  [2026-08-15T08:48:11+0200] absorbed stale (paused, awaiting external, age 22s): fleet:fm-vendor-quota
  [2026-08-15T08:48:12+0200] absorbed stale (paused, awaiting external, age 23s): fleet:fm-vendor-quota
  [2026-08-15T08:48:14+0200] absorbed stale (paused, awaiting external, age 25s): fleet:fm-vendor-quota

Safety bounds re-checked on the FIXED watcher:
  A. pane with NO declared pause  (last event: blocked: ...), agent still alive
     first look : SURFACED -> stale: fleet:fm-blocked
     pause cadence marker .paused-fleet_fm-blocked taken: no  <- blocked: never borrows the pause cadence
     idle 500s  : SURFACED -> stale: fleet:fm-blocked (idle 500s, possible wedge, escalation 1)
  
  B. declared pause on a live agent, and then that agent DIES
     live pause : quiet (absorbed, captain not interrupted)
     now dead   : SURFACED -> stale: fleet:fm-gate (paused 501s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
  
  C. declared pause absorbed, then the crew posts a NEW status event
     paused     : quiet (absorbed, captain not interrupted)
     new event  : SURFACED -> signal: /var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T//fm-paused-safety-evidence.OdZSYt/pause-then-event/state/res.status
     next look  : SURFACED -> stale: fleet:fm-resume
     pause cadence marker .paused-fleet_fm-resume still held: no  <- back to the normal cadence

Custom pause verb (FM_CLASSIFY_PAUSED_VERB=on-hold) gets the same long cadence:
  look 1  ... quiet - supervision kept watching, captain not interrupted
  look 2  ... quiet - supervision kept watching, captain not interrupted
  look 3  ... quiet - supervision kept watching, captain not interrupted
    captain interrupted about this task: 0 time(s) in 3 looks
Evidence: Bornes de sécurité vérifiées sur le watcher corrigé (blocked intact, mort non masquée, retour cadence normale)

= SAFETY BOUNDS - watcher: AFTER the fix - target commit 48df450 = A. pane with NO declared pause (last event: blocked: ...), agent still alive first look : SURFACED -> stale: fleet:fm-blocked pause cadence marker .paused-fleet_fm-blocked taken: no <- blocked: never borrows the pause cadence idle 500s : SURFACED -> stale: fleet:fm-blocked (idle 500s, possible wedge, escalation 1) B. declared pause on a live agent, and then that agent DIES live pause : quiet (absorbed, captain not interrupted) now dead : SURFACED -> stale: fleet:fm-gate (paused 501s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds) C. declared pause absorbed, then the crew posts a NEW status event paused : quiet (absorbed, captain not interrupted) new event : SURFACED -> signal: .../pause-then-event/state/res.status next look : SURFACED -> stale: fleet:fm-resume pause cadence marker .paused-fleet_fm-resume still held: no <- back to the normal cadence

==============================================================================
 SAFETY BOUNDS - watcher: AFTER the fix - target commit 48df450
==============================================================================

A. pane with NO declared pause  (last event: blocked: ...), agent still alive
   first look : SURFACED -> stale: fleet:fm-blocked
   pause cadence marker .paused-fleet_fm-blocked taken: no  <- blocked: never borrows the pause cadence
   idle 500s  : SURFACED -> stale: fleet:fm-blocked (idle 500s, possible wedge, escalation 1)

B. declared pause on a live agent, and then that agent DIES
   live pause : quiet (absorbed, captain not interrupted)
   now dead   : SURFACED -> stale: fleet:fm-gate (paused 501s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)

C. declared pause absorbed, then the crew posts a NEW status event
   paused     : quiet (absorbed, captain not interrupted)
   new event  : SURFACED -> signal: /var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T//fm-paused-safety-evidence.OdZSYt/pause-then-event/state/res.status
   next look  : SURFACED -> stale: fleet:fm-resume
   pause cadence marker .paused-fleet_fm-resume still held: no  <- back to the normal cadence
Evidence: Transcription complète AVANT le correctif (code de base f1a4af4) — 6 alertes stale sur 6 regards
==============================================================================
 SCENARIO   task 'fleet:fm-vendor-quota', last status event:
              paused: waiting on the upstream vendor to approve the API quota bump
            agent process STILL ALIVE (pane_current_command = grok), pane healthy + idle
 WATCHER    BEFORE the fix - base commit f1a4af4
 CAPTAIN    supervision looks at the task 6 times during the external wait
            (pane idle 500s each time - well past the 240s wedge threshold)
==============================================================================
look 1  >>> CAPTAIN INTERRUPTED: stale: fleet:fm-vendor-quota
look 2  >>> CAPTAIN INTERRUPTED: stale: fleet:fm-vendor-quota
look 3  >>> CAPTAIN INTERRUPTED: stale: fleet:fm-vendor-quota
look 4  >>> CAPTAIN INTERRUPTED: stale: fleet:fm-vendor-quota
look 5  >>> CAPTAIN INTERRUPTED: stale: fleet:fm-vendor-quota
look 6  >>> CAPTAIN INTERRUPTED: stale: fleet:fm-vendor-quota

--- watcher triage log (what supervision decided) ------------------------
    (empty)

--- every stale wake this task pushed into the durable wake queue --------
    stale: fleet:fm-vendor-quota
    stale: fleet:fm-vendor-quota
    stale: fleet:fm-vendor-quota
    stale: fleet:fm-vendor-quota
    stale: fleet:fm-vendor-quota
    stale: fleet:fm-vendor-quota
    -> 6 stale wake(s) delivered to the captain

RESULT [BEFORE the fix - base commit f1a4af4]
  captain interrupted about this task: 6 time(s) in 6 looks
  wedge timer   .stale-since-fleet_fm-vendor-quota  left running: no
  long pause cadence .paused-fleet_fm-vendor-quota  in force:     yes
Evidence: Transcription complète APRÈS le correctif (48df450) — 0 alerte, absorptions inscrites au journal de triage
==============================================================================
 SCENARIO   task 'fleet:fm-vendor-quota', last status event:
              paused: waiting on the upstream vendor to approve the API quota bump
            agent process STILL ALIVE (pane_current_command = grok), pane healthy + idle
 WATCHER    AFTER the fix - target commit 48df450
 CAPTAIN    supervision looks at the task 6 times during the external wait
            (pane idle 500s each time - well past the 240s wedge threshold)
==============================================================================
look 1  ... quiet - supervision kept watching, captain not interrupted
look 2  ... quiet - supervision kept watching, captain not interrupted
look 3  ... quiet - supervision kept watching, captain not interrupted
look 4  ... quiet - supervision kept watching, captain not interrupted
look 5  ... quiet - supervision kept watching, captain not interrupted
look 6  ... quiet - supervision kept watching, captain not interrupted

--- watcher triage log (what supervision decided) ------------------------
    [2026-08-15T08:48:11+0200] absorbed stale (paused, awaiting external, age 22s): fleet:fm-vendor-quota
    [2026-08-15T08:48:12+0200] absorbed stale (paused, awaiting external, age 23s): fleet:fm-vendor-quota
    [2026-08-15T08:48:14+0200] absorbed stale (paused, awaiting external, age 25s): fleet:fm-vendor-quota
    [2026-08-15T08:48:15+0200] absorbed stale (paused, awaiting external, age 26s): fleet:fm-vendor-quota
    [2026-08-15T08:48:18+0200] absorbed stale (paused, awaiting external, age 29s): fleet:fm-vendor-quota
    [2026-08-15T08:48:19+0200] absorbed stale (paused, awaiting external, age 30s): fleet:fm-vendor-quota
    [2026-08-15T08:48:21+0200] absorbed stale (paused, awaiting external, age 32s): fleet:fm-vendor-quota
    [2026-08-15T08:48:22+0200] absorbed stale (paused, awaiting external, age 33s): fleet:fm-vendor-quota

--- every stale wake this task pushed into the durable wake queue --------
    (none - the wait never produced a wake)

RESULT [AFTER the fix - target commit 48df450]
  captain interrupted about this task: 0 time(s) in 6 looks
  wedge timer   .stale-since-fleet_fm-vendor-quota  left running: no
  long pause cadence .paused-fleet_fm-vendor-quota  in force:     yes
Evidence: Verbe de pause configurable (FM_CLASSIFY_PAUSED_VERB=on-hold) — même cadence longue
==============================================================================
 SCENARIO   task 'fleet:fm-vendor-quota', last status event:
              on-hold: waiting on the upstream vendor to approve the API quota bump
            agent process STILL ALIVE (pane_current_command = grok), pane healthy + idle
 WATCHER    AFTER the fix, custom FM_CLASSIFY_PAUSED_VERB=on-hold
 CAPTAIN    supervision looks at the task 3 times during the external wait
            (pane idle 500s each time - well past the 240s wedge threshold)
==============================================================================
look 1  ... quiet - supervision kept watching, captain not interrupted
look 2  ... quiet - supervision kept watching, captain not interrupted
look 3  ... quiet - supervision kept watching, captain not interrupted

--- watcher triage log (what supervision decided) ------------------------
    [2026-08-15T08:47:13+0200] absorbed stale (paused, awaiting external, age 3s): fleet:fm-vendor-quota
    [2026-08-15T08:47:14+0200] absorbed stale (paused, awaiting external, age 4s): fleet:fm-vendor-quota
    [2026-08-15T08:47:17+0200] absorbed stale (paused, awaiting external, age 7s): fleet:fm-vendor-quota
    [2026-08-15T08:47:18+0200] absorbed stale (paused, awaiting external, age 8s): fleet:fm-vendor-quota
    [2026-08-15T08:47:19+0200] absorbed stale (paused, awaiting external, age 9s): fleet:fm-vendor-quota
    [2026-08-15T08:47:22+0200] absorbed stale (paused, awaiting external, age 12s): fleet:fm-vendor-quota
    [2026-08-15T08:47:24+0200] absorbed stale (paused, awaiting external, age 14s): fleet:fm-vendor-quota
    [2026-08-15T08:47:25+0200] absorbed stale (paused, awaiting external, age 15s): fleet:fm-vendor-quota

--- every stale wake this task pushed into the durable wake queue --------
    (none - the wait never produced a wake)

RESULT [AFTER the fix, custom FM_CLASSIFY_PAUSED_VERB=on-hold]
  captain interrupted about this task: 0 time(s) in 3 looks
  wedge timer   .stale-since-fleet_fm-vendor-quota  left running: no
  long pause cadence .paused-fleet_fm-vendor-quota  in force:     yes
Evidence: Harnais de reproduction du scénario principal (rejouable contre n'importe quel bin/)
#!/usr/bin/env bash
# End-to-end supervision scenario for "a declared paused: on a still-live agent".
#
# Drives the REAL fm-watch.sh (the code under test is never stubbed) over the
# repo's own hermetic tmux / fm-crew-state fixture and simulates what a captain
# actually lives through during ONE long, legitimate external wait:
#   * the crew's last status event is  paused: <external wait>
#   * its agent process is still alive and attached (pane_current_command=grok)
#   * the pane is healthy and idle, and - like every real attached agent - its
#     rendering churns slightly (an elapsed-time counter ticks), so supervision
#     re-classifies a fresh stale hash each time it looks
#   * the pane has been idle far past the 240s wedge threshold the whole time
#
# Usage: bash paused-live-agent-scenario.sh <worktree> <bin-dir> <label>
set -u
WT=$1; BIN=$2; LABEL=$3
ROUNDS=${ROUNDS:-6}
# shellcheck source=/dev/null
. "$WT/tests/wake-helpers.sh"
TMP_ROOT=$(fm_test_tmproot fm-paused-live-evidence)
WATCH="$BIN/fm-watch.sh"; DRAIN="$BIN/fm-wake-drain.sh"

seen_sig() {
  if [ "$(uname)" = Darwin ]; then stat -f '%z:%Fm' "$1"; else stat -c '%s:%Y' "$1"; fi
}
drain_and_ack() {
  local err="$STATE/.evidence-drain.err" s g
  FM_STATE_OVERRIDE="$STATE" "$DRAIN" >/dev/null 2> "$err" || true
  s=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation [A-Za-z0-9._-][A-Za-z0-9._-]*$/\1/p' "$err")
  g=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through [0-9][0-9]* --recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$err")
  if [ -n "$s" ] && [ -n "$g" ]; then
    FM_STATE_OVERRIDE="$STATE" "$DRAIN" --ack-through "$s" --recovery-generation "$g" >/dev/null 2>&1 || true
  fi
}

dir=$(make_case vendor-quota-wait)
STATE="$dir/state"; FAKEBIN="$dir/fakebin"
WINDOW="fleet:fm-vendor-quota"
KEY=$(printf '%s' "$WINDOW" | tr ':/.' '___')
PANE="$dir/pane.txt"
printf 'window=%s\nkind=ship\nharness=grok\nbackend=tmux\n' "$WINDOW" > "$STATE/vendor.meta"
VERB=${FM_CLASSIFY_PAUSED_VERB:-paused}
printf 'working: opened the quota request\n%s: waiting on the upstream vendor to approve the API quota bump\n' "$VERB" \
  > "$STATE/vendor.status"
seen_sig "$STATE/vendor.status" > "$STATE/.seen-vendor_status"

echo "=============================================================================="
echo " SCENARIO   task '$WINDOW', last status event:"
echo "              $VERB: waiting on the upstream vendor to approve the API quota bump"
echo "            agent process STILL ALIVE (pane_current_command = grok), pane healthy + idle"
echo " WATCHER    $LABEL"
echo " CAPTAIN    supervision looks at the task $ROUNDS times during the external wait"
echo "            (pane idle 500s each time - well past the 240s wedge threshold)"
echo "=============================================================================="
alerts=0
r=1
while [ "$r" -le "$ROUNDS" ]; do
  # A live attached agent's pane is never byte-frozen: its elapsed-wait counter
  # ticks, so each look presents a fresh stable stale hash.
  printf '%s\n' \
    "> $VERB: waiting on the upstream vendor to approve the API quota bump" \
    "  waited ${r}h so far - nothing local to do until they answer" > "$PANE"
  hash_text "$(cat "$PANE")" > "$STATE/.hash-$KEY"
  printf '1\n' > "$STATE/.count-$KEY"
  printf '%s\n' $(( $(date +%s) - 500 )) > "$STATE/.stale-since-$KEY"
  : > "$dir/round.out"
  PATH="$FAKEBIN:$PATH" FM_FAKE_TMUX_WINDOW="$WINDOW" FM_FAKE_TMUX_CAPTURE="$PANE" \
    FM_FAKE_TMUX_CURRENT_COMMAND=grok \
    FM_FAKE_CREW_STATE='state: paused · source: status-log · waiting on the upstream vendor to approve the API quota bump' \
    FM_STATE_OVERRIDE="$STATE" FM_CREW_STATE_BIN="$FAKEBIN/fm-crew-state.sh" \
    FM_CLASSIFY_PAUSED_VERB="$VERB" \
    FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=99999 \
    FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \
    "$WATCH" > "$dir/round.out" 2> "$dir/round.err" &
  pid=$!
  i=0; exited=no
  while [ "$i" -lt 30 ]; do
    kill -0 "$pid" 2>/dev/null || { exited=yes; break; }
    sleep 0.1; i=$((i+1))
  done
  if [ "$exited" = yes ]; then
    wait "$pid" 2>/dev/null
    reason=$(cat "$dir/round.out")
    case "$reason" in
      "check: rearm-resurface")
        # Watcher-restart bookkeeping from this harness killing the previous
        # watcher; not a verdict about the task. Ack it and re-take this look.
        drain_and_ack
        continue
        ;;
    esac
    alerts=$((alerts+1))
    printf 'look %d  >>> CAPTAIN INTERRUPTED: %s\n' "$r" "$reason"
    awk -F '\t' -v w="$WINDOW" '$3=="stale" && $4==w { print $5 }' "$STATE/.wake-queue" >> "$dir/queued.txt"
    drain_and_ack
  else
    kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
    printf 'look %d  ... quiet - supervision kept watching, captain not interrupted\n' "$r"
  fi
  r=$((r+1))
done
echo
echo "--- watcher triage log (what supervision decided) ------------------------"
if [ -s "$STATE/.watch-triage.log" ]; then sed 's/^/    /' "$STATE/.watch-triage.log" | tail -8; else echo "    (empty)"; fi
echo
echo "--- every stale wake this task pushed into the durable wake queue --------"
if [ -s "$dir/queued.txt" ]; then
  sed 's/^/    /' "$dir/queued.txt"
  printf '    -> %s stale wake(s) delivered to the captain\n' "$(wc -l < "$dir/queued.txt" | tr -d ' ')"
else
  echo "    (none - the wait never produced a wake)"
fi
echo
echo "RESULT [$LABEL]"
echo "  captain interrupted about this task: $alerts time(s) in $ROUNDS looks"
echo "  wedge timer   .stale-since-$KEY  left running: $([ -e "$STATE/.stale-since-$KEY" ] && echo yes || echo no)"
echo "  long pause cadence .paused-$KEY  in force:     $([ -e "$STATE/.paused-$KEY" ] && echo yes || echo no)"
Evidence: Harnais de reproduction des bornes de sécurité
#!/usr/bin/env bash
# Safety bounds the long pause cadence must NOT weaken, exercised end-to-end
# against the real fm-watch.sh over the repo's hermetic backend fixture:
#
#   A. a pane with NO declared pause (blocked:, live agent) still surfaces and
#      still wedge-escalates - blocked semantics untouched
#   B. a declared pause on a live agent is absorbed, and when that agent then
#      DIES the death is still detected - it re-surfaces as a paused recheck,
#      never masked, never mislabelled a wedge
#   C. a declared pause on a live agent is absorbed, and the next real event
#      (a new status line) returns the pane to the normal cadence
#
# Usage: bash safety-bounds-scenario.sh <worktree> <bin-dir> <label>
set -u
WT=$1; BIN=$2; LABEL=$3
# shellcheck source=/dev/null
. "$WT/tests/wake-helpers.sh"
TMP_ROOT=$(fm_test_tmproot fm-paused-safety-evidence)
WATCH="$BIN/fm-watch.sh"; DRAIN="$BIN/fm-wake-drain.sh"

seen_sig() {
  if [ "$(uname)" = Darwin ]; then stat -f '%z:%Fm' "$1"; else stat -c '%s:%Y' "$1"; fi
}
backdate() {  # <file> <seconds-ago>
  local f=$1 b; b=$(( $(date +%s) - $2 ))
  if [ "$(uname)" = Darwin ]; then touch -mt "$(date -r "$b" '+%Y%m%d%H%M.%S')" "$f"
  else touch -m -d "@$b" "$f"; fi
}
drain_and_ack() {  # <state>
  local st=$1 err="$1/.evidence-drain.err" s g
  FM_STATE_OVERRIDE="$st" "$DRAIN" >/dev/null 2> "$err" || true
  s=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation [A-Za-z0-9._-][A-Za-z0-9._-]*$/\1/p' "$err")
  g=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through [0-9][0-9]* --recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$err")
  if [ -n "$s" ] && [ -n "$g" ]; then
    FM_STATE_OVERRIDE="$st" "$DRAIN" --ack-through "$s" --recovery-generation "$g" >/dev/null 2>&1 || true
  fi
}
# look <state> <fakebin> <window> <pane-file> <cmd> <crew-state> <outfile> [extra env...]
look() {
  local st=$1 fb=$2 win=$3 pane=$4 cmd=$5 crew=$6 out=$7; shift 7
  : > "$out"
  env "$@" PATH="$fb:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
    FM_FAKE_TMUX_CURRENT_COMMAND="$cmd" FM_FAKE_CREW_STATE="$crew" \
    FM_STATE_OVERRIDE="$st" FM_CREW_STATE_BIN="$fb/fm-crew-state.sh" \
    FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \
    "$WATCH" > "$out" 2>/dev/null &
  local pid=$! i=0
  while [ "$i" -lt 30 ]; do
    kill -0 "$pid" 2>/dev/null || { wait "$pid" 2>/dev/null; LOOK_EXITED=yes; return 0; }
    sleep 0.1; i=$((i+1))
  done
  kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
  LOOK_EXITED=no
}
report() {  # <out> <prefix>
  if [ "$LOOK_EXITED" = yes ]; then printf '%s SURFACED -> %s\n' "$2" "$(cat "$1")"
  else printf '%s quiet (absorbed, captain not interrupted)\n' "$2"; fi
}

echo "=============================================================================="
echo " SAFETY BOUNDS - watcher: $LABEL"
echo "=============================================================================="

# --- A: no declared pause (blocked:) on a live agent -------------------------
dir=$(make_case blocked-live); ST="$dir/state"; FB="$dir/fakebin"
W="fleet:fm-blocked"; K=$(printf '%s' "$W" | tr ':/.' '___')
P="$dir/pane.txt"
printf 'blocked: needs a captain decision on the schema rollback\n' > "$P"
printf 'window=%s\nkind=ship\nharness=grok\nbackend=tmux\n' "$W" > "$ST/blk.meta"
printf 'blocked: needs a captain decision on the schema rollback\n' > "$ST/blk.status"
seen_sig "$ST/blk.status" > "$ST/.seen-blk_status"
hash_text "$(cat "$P")" > "$ST/.hash-$K"; printf '1\n' > "$ST/.count-$K"
echo
echo "A. pane with NO declared pause  (last event: blocked: ...), agent still alive"
look "$ST" "$FB" "$W" "$P" grok 'state: unknown · source: pane · idle' "$dir/out" \
  FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=99999
report "$dir/out" "   first look :"
echo "   pause cadence marker .paused-$K taken: $([ -e "$ST/.paused-$K" ] && echo yes || echo 'no  <- blocked: never borrows the pause cadence')"
drain_and_ack "$ST"
printf '%s\n' $(( $(date +%s) - 500 )) > "$ST/.stale-since-$K"
look "$ST" "$FB" "$W" "$P" grok 'state: working · source: run · pipeline running' "$dir/out2" \
  FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=99999
report "$dir/out2" "   idle 500s  :"

# --- B: declared pause absorbed, then the agent dies -------------------------
dir=$(make_case pause-then-death); ST="$dir/state"; FB="$dir/fakebin"
W="fleet:fm-gate"; K=$(printf '%s' "$W" | tr ':/.' '___')
P="$dir/pane.txt"
printf 'idle at an external-decision gate\n' > "$P"
printf 'window=%s\nkind=ship\nharness=grok\nbackend=tmux\n' "$W" > "$ST/gate.meta"
printf 'paused: waiting at an active external-decision gate\n' > "$ST/gate.status"
seen_sig "$ST/gate.status" > "$ST/.seen-gate_status"
hash_text "$(cat "$P")" > "$ST/.hash-$K"; printf '1\n' > "$ST/.count-$K"
echo
echo "B. declared pause on a live agent, and then that agent DIES"
look "$ST" "$FB" "$W" "$P" grok 'state: paused · source: status-log · waiting at an external gate' "$dir/out" \
  FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=99999
report "$dir/out" "   live pause :"
drain_and_ack "$ST"
printf 'idle bare shell after the agent died\n' > "$P"
backdate "$ST/gate.status" 500
seen_sig "$ST/gate.status" > "$ST/.seen-gate_status"
hash_text "$(cat "$P")" > "$ST/.hash-$K"; printf '1\n' > "$ST/.count-$K"
look "$ST" "$FB" "$W" "$P" zsh 'state: stopped · source: pane · bare shell' "$dir/out2" \
  FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=240
report "$dir/out2" "   now dead   :"
if grep -qF 'possible wedge' "$dir/out2" 2>/dev/null; then echo "   ** mislabelled a wedge **"; fi

# --- C: declared pause absorbed, then a new status event lands ---------------
dir=$(make_case pause-then-event); ST="$dir/state"; FB="$dir/fakebin"
W="fleet:fm-resume"; K=$(printf '%s' "$W" | tr ':/.' '___')
P="$dir/pane.txt"
printf 'idle while the vendor decides\n' > "$P"
printf 'window=%s\nkind=ship\nharness=grok\nbackend=tmux\n' "$W" > "$ST/res.meta"
printf 'paused: waiting on the vendor\n' > "$ST/res.status"
seen_sig "$ST/res.status" > "$ST/.seen-res_status"
hash_text "$(cat "$P")" > "$ST/.hash-$K"; printf '1\n' > "$ST/.count-$K"
echo
echo "C. declared pause absorbed, then the crew posts a NEW status event"
look "$ST" "$FB" "$W" "$P" grok 'state: paused · source: status-log · waiting on the vendor' "$dir/out" \
  FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=99999
report "$dir/out" "   paused     :"
drain_and_ack "$ST"
printf 'paused: waiting on the vendor\nneeds-decision: the vendor refused - pick a fallback provider\n' > "$ST/res.status"
look "$ST" "$FB" "$W" "$P" grok 'state: unknown · source: pane · idle' "$dir/out2" \
  FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=99999
report "$dir/out2" "   new event  :"
drain_and_ack "$ST"
look "$ST" "$FB" "$W" "$P" grok 'state: unknown · source: pane · idle' "$dir/out3" \
  FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=99999
report "$dir/out3" "   next look  :"
echo "   pause cadence marker .paused-$K still held: $([ -e "$ST/.paused-$K" ] && echo yes || echo 'no  <- back to the normal cadence')"

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ bin/fm-watch.sh:397 - Dans le fast path de pause_state_class (bin/fm-watch.sh:396-397), fm_backend_agent_alive est sondé à chaque poll (défaut FM_POLL=15s) même quand le dernier statut est paused:, alors que dans ce cas le résultat de la sonde ne peut plus changer la décision (la condition modifiée court-circuite sur status_is_paused). Tester d'abord ! status_is_paused &#34;$last&#34; (pur test de chaîne) et n'exécuter la sonde backend qu'ensuite éviterait une requête backend (tmux/herdr) toutes les 15 s par pane en pause pendant toute une longue attente externe. Attention : uniquement le bloc fast-path (lignes 395-402) ; le second bloc (ligne 413) doit conserver sa sonde car agent_alive y est consommé par la promotion none+dead→paused de la ligne 420.
  • ℹ️ tests/fm-watch-triage.test.sh:900 - Interprétation de la borne « endpoint-death detection keeps priority over the long cadence » : après la mort d'un agent absorbé en pause vivante, la détection de mort ne devance pas la cadence — la re-surface n'arrive qu'à la prochaine fenêtre PAUSE_RESURFACE_SECS (≤1h par défaut, ancrée sur le mtime du fichier de statut), jamais immédiatement. C'est identique à la sémantique préexistante des agents exités que l'intention désigne comme référence (« exactly the semantics already served to exited agents »), et le nouveau test test_live_paused_agent_death_still_rechecks encode délibérément ce compromis (re-surface bornée étiquetée « paused recheck », jamais masquée, jamais « possible wedge »). Aucune action requise ; noté pour que l'auteur puisse confirmer cette lecture.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-watch-triage.test.sh — suite propriétaire du triage watcher : 50 ok, 0 échec, sortie 0 (couvre test_exited_declared_pause_is_bounded_but_live_gate_absorbed réécrit et le nouveau test_live_paused_agent_death_still_rechecks)
  • Scénario produit A/B contre le vrai bin/fm-watch.sh : bash paused-live-agent-scenario.sh &lt;worktree&gt; &lt;EV&gt;/baseline/bin &#34;BEFORE&#34; (bin extrait de f1a4af4 via git archive) → 6 alertes stale: sur 6 regards
  • bash paused-live-agent-scenario.sh &lt;worktree&gt; &lt;worktree&gt;/bin &#34;AFTER&#34; → 0 alerte sur 6 regards, 8 lignes absorbed stale (paused, awaiting external…) dans .watch-triage.log
  • bash safety-bounds-scenario.sh &lt;worktree&gt; &lt;worktree&gt;/bin — phase A : pane blocked: + agent vivant surface puis escalade possible wedge, escalation 1 et ne prend pas .paused-*
  • bash safety-bounds-scenario.sh — phase B : pause vivante absorbée, puis agent mort → resurface en stale: … (paused 501s, awaiting external … not a wedge), aucune mention possible wedge
  • bash safety-bounds-scenario.sh — phase C : nouvel événement de statut après paused: → réveil signal: immédiat puis stale: au regard suivant, .paused-* relâché
  • bash safety-bounds-scenario.sh &lt;worktree&gt; &lt;EV&gt;/baseline/bin — phase A rejouée sur le code de base : sortie identique, sémantique blocked:/wedge inchangée
  • FM_CLASSIFY_PAUSED_VERB=on-hold ROUNDS=3 bash paused-live-agent-scenario.sh … &lt;worktree&gt;/bin → 0 interruption, le verbe configurable obtient la même cadence longue
  • git status --porcelain --untracked-files=all après nettoyage du répertoire transitoire .evidence-baseline/ → arbre de travail propre
🔧 **Document** - 1 issue found → auto-fixed ✅
  • ℹ️ tests/fm-watch-triage.test.sh:895 - Le commentaire de test ajouté par ce changement contient un mot français dans une prose anglaise : « endpoint-mort detection keeps priority over the long cadence » devrait lire « endpoint-death detection ». Non corrigé parce que cette phase ne peut pas modifier les tests ; correction triviale à appliquer par la phase qui possède tests/fm-watch-triage.test.sh.

🔧 Fix: docs: fix endpoint-death typo in pause cadence test comment
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

A declared paused: was ignored by supervision while the agent was still
alive on a healthy idle pane, so a legitimate long external wait raised
repeated possible-wedge stale alerts. pause_state_class only returned
the bounded pause cadence for an exited agent; a live agent fell through
to the none (surface) path. Now a declared pause absorbs on the long
cadence whether the agent is alive or has exited, while a live agent at
a captain-held (non-pause) gate still surfaces once. Endpoint-death
detection keeps priority: a paused agent that subsequently dies still
re-surfaces on the bounded cadence for a recheck, and an agent that
resumes working returns to normal wedge cadence.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant