Skip to content

feat(bin): auto-recover a herdr secondmate whose agent froze mid-turn - #6878

Open
mitu1306 wants to merge 5 commits into
kunchenguid:mainfrom
mituso89:fm/macmini-freeze-autorecover
Open

mitu1306 wants to merge 5 commits into
kunchenguid:mainfrom
mituso89:fm/macmini-freeze-autorecover

Conversation

@mitu1306

@mitu1306 mitu1306 commented Oct 8, 2026

Copy link
Copy Markdown

Intent

Automatic freeze recovery: go-ahead to build it? - yes.

Context. The remote secondmate secondmate_outreach (Claude Code in herdr session fm-remote on host mac-mini) froze three times on 2026-10-06/07: the TUI stopped on "running PreToolUse hooks" with the timer stopped, the process alive at 0% CPU with no children, SIGTERM ignored; each time the captain had to SIGKILL it by hand before it could be relaunched.
The investigation report data/macmini-claude-freeze/report.md (in the firstmate home; read sections 7 and 8 in full) found an upstream Claude Code deadlock, not Firstmate hooks or a version regression, and recommended the fix approved here: bin/fm-secondmate-liveness-lib.sh already probes secondmate endpoints on each watcher tick and relaunches dead ones through FM_SPAWN_NO_GUARD=1 bin/fm-spawn.sh --secondmate under a per-mate lock with a bounded relaunch ledger, but fm_backend_herdr_agent_state classifies a frozen Claude as alive, so it is never recovered. herdr exposes progress counters (state_change_seq from herdr agent get <pane>, scroll.max_offset_from_bottom from herdr pane list) that stop advancing when the session freezes.
The approved design: a distinct wedged state (agent working, no progress across a generous configurable window, suggested 10+ minutes, sampled on the existing watcher tick); on the first wedged verdict capture evidence non-destructively (macOS sample <pid> 10 plus lsof -p <pid>, Linux equivalent) into the secondmate state and record it in the relaunch ledger; then SIGKILL only that pane's agent process, never the herdr server, sibling panes or worker processes, and relaunch through the existing path, reusing the existing relaunch bound so a misdetection cannot kill-loop a healthy mate; raise the existing wedge alarm (docs/wedge-alarm.md) so a recovery is never silent.

What Changed

  • Added a separate wedged liveness state for Herdr-backed secondmates (new bin/fm-herdr-wedge-lib.sh), checked on every watcher tick. A mate counts as wedged only if all of these stay frozen for the whole no-progress window while the agent still reports working: both herdr progress counters (state_change_seq and scroll.max_offset_from_bottom), the agent's CPU time, and its live non-MCP child processes. The window defaults to 900s, has a 600s floor, is set in config/secondmate-wedge-window, and can be turned off. It counts only observed time, so a long gap between samples (for example after a suspend) restarts it. Any signal that cannot be read gives no verdict, so it never leads to a kill. fm-watch.sh logs when the counters cannot be read.
  • On a wedged verdict, bin/fm-secondmate-liveness-lib.sh first saves evidence (sample/lsof on macOS, or the Linux equivalent) and records its path in the relaunch ledger. It then SIGKILLs only the harness processes in that pane's own process subtree and relaunches through the existing guarded spawn path, inside the existing relaunch bound. Remote mates get new wedge-state and wedge-recover verbs in fm-remote-secondmate-control.sh, so the probe, capture and kill all run on the mate's own host. The supervising home still sets the window and the sample-gap limit. fm-teardown.sh removes the new per-mate wedge state file.
  • Every recovery raises the wedge alarm, whether or not the relaunch succeeds. To support this, the alarm code moved out of fm-supervise-daemon.sh into a shared bin/fm-wedge-alarm-lib.sh with a title each caller can set. Process-table and descendant helpers moved out of backends/herdr.sh into fm-agent-process-lib.sh. Docs were updated (configuration, wedge-alarm, remote-secondmates, herdr-backend, scripts, architecture), and remote and local wedge cases were added to tests/fm-secondmate-liveness.test.sh.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The fix round correctly parses both BSD [HH:]MM:SS.CC and Linux [DD-]HH:MM:SS cputime to centiseconds and rejects anything else. It tags stored CPU values with a unit so older records restart the window instead of reading as equal. It derives the sample-gap limit from the watcher cadence, both locally and when the parent passes it to a remote mate. Tests use fm_agent_standin and include controls that would fail if the code were wrong, and nothing in the change contradicts the stated intent.

Testing

In this test step I ran two isolated herdr labs against a real, SIGSTOP-frozen lab Claude. The first was a 12-minute watcher-tick run. It showed the full chain in order: wedged verdict at about 600s, evidence capture, ledger rows, an agent-only SIGKILL, pane close, relaunch call, and the wedge alarm. The second was a short kill-scope run. It showed that after fm_secondmate_liveness_wedge_recover the pane shell, the pane, and a process outside the pane all survived; only the claude pid died. The 12-minute run took place earlier in this same test step, before my context restarted. I report it from the log and files it wrote at 22:47-22:58, which is after the HEAD commit (22:19). I also ran the targeted test file (40/40 pass), which covers the macOS BSD and Linux cputime formats and the stubbed remote transport. No pattern-based kills were used. Both labs were torn down, and the default and fm-remote sessions were untouched. This is a CLI/daemon change with no UI, so there are no screenshots; pane captures and logs are the evidence.

  • Live validation: ✅ go - 7 of 9 scenarios driven live against the product
Scenario Result Live Evidence
A frozen working Claude in a herdr pane gets no verdict before the 600s window, then gets a wedged verdict on the next watcher tick ✅ pass live last-attempt/driver-log.txt: ticks 0-10 report baseline/progressing with seq 3, offset 0 and cpu 600cs all flat; tick 11 (22:58:41, about 652s after the 22:47:49 baseline) reports status=relaunchable…
Recovery captures non-destructive evidence to a file in the secondmate state before the kill ✅ pass live last-attempt/wedge-capture.txt (from /tmp/fm-lab.jSM1vw/state/.wedge-sample-smlab-1791475122.txt) shows the process still in 'State: T (stopped)' at capture time. The kill-scope run did the same: kill…
SIGKILL lands only on the agent pid; the pane shell and a process outside the pane subtree survive ✅ pass live Full run: killed=[309690] (the claude pid), and the outside sentinel 366019 survived. Kill-scope run (killscope/ps-after-kill.txt): only claude 878852 was killed; pane shell zsh 865329 and outside pro…
After the kill, the relaunch path closes the agent-free pane ✅ pass live last-attempt/driver-log.txt tick 11: the post-relaunch herdr pane list returned panes:[] and the pane shell was gone from ps.
The relaunch ledger records the attempt, the capture path and the outcome ✅ pass live last-attempt/relaunch-ledger.txt: attempt, then wedge-capture <path>, then relaunched. Kill-scope run ledger: a wedge-capture row was written.
Relaunch goes through the existing guarded spawn path under the relaunch bound ✅ pass live last-attempt/spawn-calls.log: 'spawn argv=[smlab --secondmate] FM_SPAWN_NO_GUARD=1'. The driver checked recent_attempts=0 against max 3 before relaunching. The spawn is a lab fakeroot that only record…
The wedge alarm is raised so the recovery is never silent ✅ pass live last-attempt/alarm.log: 'secondmate smlab was wedged (running, no progress); killed agent pid(s) 309690 and relaunched. Evidence: ...'
macOS BSD cputime (M:SS.CC) is parsed, so detection works on the mac-mini ⏸️ untested no This host is Linux (WSL2) and no lab macOS host is available. The only Mac is the real mac-mini, which the user forbade touching. To drive it live, provide a disposable macOS lab host with herdr and C…
Remote secondmate: wedge-state and wedge-recover over the fm-on.sh transport, with no cross-host signalling ⏸️ untested no No disposable remote lab host exists, and the user told me not to use a real remote host. To drive it live, provide an SSH-reachable lab host registered as a throwaway remote endpoint.
Evidence: 12-minute driver log (ticks 0-11, wedged verdict, relaunch, kill, pane close)

Source: 12-minute driver log (ticks 0-11, wedged verdict, relaunch, kill, pane close)

22:47:49 tick=0 mode=full status=alive state=alive kill=0 wedge=baseline record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474469]
22:48:48 tick=1 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474528]
22:49:48 tick=2 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474588]
22:50:47 tick=3 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474647]
22:51:46 tick=4 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474706]
22:52:45 tick=5 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474764]
22:53:44 tick=6 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474824]
22:54:44 tick=7 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474884]
22:55:43 tick=8 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791474943]
22:56:42 tick=9 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791475002]
22:57:42 tick=10 mode=poll status=alive state=alive kill=0 wedge=progressing record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791475062]
22:58:41 tick=11 mode=poll status=relaunchable state=wedged kill=wedged wedge=wedged record=[fm-lab-wedge12m-301279-3492:w1:p1 working 3 0 600cs 1791474469 1791475121]
22:58:41 tick=11 cause="agent wedged: running but no progress for at least 600s" where=backend=herdr recent_attempts=0
22:58:43 tick=11 relaunch rc=0 status=relaunchable reason="" capture=/tmp/fm-lab.jSM1vw/state/.wedge-sample-smlab-1791475122.txt killed=[309690]
22:58:43 tick=11 spawn output: lab fake spawn recorded
22:58:43 tick=11 post-kill ps:  366019 SN   fm-wedgelab-sentinel 3600;
22:58:43 tick=11 post-relaunch panes: {"id":"cli:pane:list","result":{"panes":[],"type":"pane_list"}} 
22:58:43 DONE after recovery
Evidence: Lab driver script (replays the fm-watch liveness tick)

Source: Lab driver script (replays the fm-watch liveness tick)

#!/usr/bin/env bash
# Lab-only driver: replays bin/fm-watch.sh's secondmate_liveness_tick body
# (probe poll -> relaunch bound check -> fm_secondmate_liveness_relaunch) every
# 60s against ONE lab secondmate whose endpoint is a pane in an fm-lab-* herdr
# session. FM_ROOT points at a lab fakeroot whose bin/fm-spawn.sh only records
# its invocation, so the relaunch cannot create panes anywhere real.
# Usage: driver.sh <worktree> <lab-home> <id> <ticks> <logfile>
set -u
WT=$1 LAB=$2 ID=$3 TICKS=$4 LOG=$5
export FM_HOME=$LAB FM_ROOT=$LAB/fakeroot STATE=$LAB/state
export FM_SECONDMATE_LIVENESS_SECS=60
unset FM_WEDGE_ALARM_EXEC FM_WEDGE_ALARM_CHANNEL
. "$WT/bin/fm-secondmate-liveness-lib.sh"
meta=$STATE/$ID.meta
log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$LOG"; }
MAX_ATTEMPTS=3 WINDOW_SECS=3600 TIMEOUT=120
for ((t = 0; t < TICKS; t++)); do
  mode=poll
  [ "$t" -gt 0 ] || mode=full
  fm_secondmate_liveness_lock "$ID" || { log "tick=$t lock busy"; sleep 60; continue; }
  fm_secondmate_liveness_probe "$meta" "$ID" "$mode"
  rec=$(tr '\t' ' ' < "$STATE/.secondmate-wedge-$ID" 2>/dev/null)
  log "tick=$t mode=$mode status=$FM_SM_LIVE_STATUS state=$FM_SM_LIVE_STATE kill=$FM_SM_LIVE_KILL wedge=$FM_SM_LIVE_WEDGE record=[$rec]"
  if [ "$FM_SM_LIVE_STATUS" = relaunchable ]; then
    attempts=$(fm_secondmate_liveness_recent_attempts "$ID" "$WINDOW_SECS")
    log "tick=$t cause=\"$FM_SM_LIVE_CAUSE\" where=$FM_SM_LIVE_WHERE recent_attempts=$attempts"
    if [ "$attempts" -lt "$MAX_ATTEMPTS" ]; then
      rc=0
      fm_secondmate_liveness_relaunch "$meta" "$ID" "$TIMEOUT" || rc=$?
      log "tick=$t relaunch rc=$rc status=$FM_SM_LIVE_STATUS reason=\"$FM_SM_LIVE_REASON\" capture=$FM_SM_LIVE_WEDGE_CAPTURE killed=[$FM_SM_LIVE_WEDGE_KILLED]"
      log "tick=$t spawn output: $(printf '%s' "$FM_SM_LIVE_OUT" | tr '\n' ' ')"
      fm_secondmate_liveness_unlock "$ID"
      log "tick=$t post-kill ps: $(ps -o pid=,stat=,args= -p "$CHECK_PIDS" 2>&1 | tr '\n' ';')"
      log "tick=$t post-relaunch panes: $(HERDR_SESSION=$HS herdr pane list --session "$HS" 2>&1 | tr '\n' ' ')"
      log "DONE after recovery"
      exit 0
    fi
  fi
  fm_secondmate_liveness_unlock "$ID"
  [ "$t" -eq $((TICKS - 1)) ] || sleep 60
done
log "DONE without a recovery"
Evidence: Evidence capture written before the kill (full run)

Source: Evidence capture written before the kill (full run)

wedged secondmate smlab
captured: 2026-10-08T22:58:42+0700
uname: Linux Access-CDH8VL3 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
pids: 309690

===== pid 309690 =====
--- /proc/309690/status ---
Name:	claude
Umask:	0002
State:	T (stopped)
Tgid:	309690
Ngid:	0
Pid:	309690
PPid:	309689
TracerPid:	0
Uid:	1000	1000	1000	1000
Gid:	1000	1000	1000	1000
FDSize:	1024
Groups:	4 24 27 30 46 100 989 1000 
NStgid:	309690
NSpid:	309690
NSpgid:	309689
NSsid:	306657
Kthread:	0
VmPeak:	 9755436 kB
VmSize:	 5610276 kB
VmLck:	       0 kB
VmPin:	       0 kB
VmHWM:	  302644 kB
VmRSS:	  295868 kB
RssAnon:	  165976 kB
RssFile:	  129892 kB
RssShmem:	       0 kB
VmData:	 5521808 kB
VmStk:	     136 kB
VmExe:	   61216 kB
VmLib:	    2272 kB
VmPTE:	    1104 kB
VmSwap:	       0 kB
HugetlbPages:	       0 kB
CoreDumping:	0
THP_enabled:	1
untag_mask:	0xffffffffffffffff
Threads:	17
SigQ:	2/47811
SigPnd:	0000000000000000
ShdPnd:	0000000000000000
SigBlk:	0000000000004000
SigIgn:	0000000000001000
SigCgt:	000000017b826cff
CapInh:	0000000000000000
CapPrm:	0000000000000000
CapEff:	0000000000000000
CapBnd:	000001ffffffffff
CapAmb:	0000000000000000
NoNewPrivs:	0
Seccomp:	0
Seccomp_filters:	0
Speculation_Store_Bypass:	thread vulnerable
SpeculationIndirectBranch:	conditional enabled
Cpus_allowed:	3f
Cpus_allowed_list:	0-5
Mems_allowed:	00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000001
Mems_allowed_list:	0
voluntary_ctxt_switches:	3093
nonvoluntary_ctxt_switches:	865
--- per-thread wchan/stat ---
309690 wchan=do_signal_stop stat=T
309692 wchan=do_signal_stop stat=T
309720 wchan=do_signal_stop stat=Pool
309721 wchan=do_signal_stop stat=Pool
309736 wchan=do_signal_stop stat=Pool
309737 wchan=do_signal_stop stat=Pool
309746 wchan=do_signal_stop stat=Client)
309749 wchan=do_signal_stop stat=Pool
309750 wchan=do_signal_stop stat=Pool
309763 wchan=do_signal_stop stat=T
358694 wchan=do_signal_stop stat=T
358695 wchan=do_signal_stop stat=T
358696 wchan=do_signal_stop stat=T
358697 wchan=do_signal_stop stat=T
358698 wchan=do_signal_stop stat=T
360667 wchan=do_signal_stop stat=T
360668 wchan=do_signal_stop stat=T
--- open files ---
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/182eb75b2238506eb15f6a05f4bdfbd93d72fa0f919443d3dd3cd337375afd10
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/bc653b3851e84345e39887c19eaa810d6a2b8f86582ce965cdac86d3445dbab9
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/4950b4ad6c14
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/92a587dabacc
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/ac8680aaabfefd829f5617e9fe84cffb0f680643f0dbaf7c360305a92fcb32ba
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/490d7f302ae1
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/a75dac03f98980f65673cbebceffa9519a0b4e4f5e85889a576a1987cd425895
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/f3a8bc11289dc1cc0f790459fc9537d0e067d2920752ebc4335aa1827ea65bbe
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/5682784e45b8
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/1319c185708a
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/5d63f6b6e973b186f31af69b70a1874efc3559fdcbbab92ba57f03882e2cce05
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/f4d2de379826
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/7d4f3cdc2764206e2fda987a4180fa8ac70095cd6a29f20329899f04859e7d80
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/f4d41bc37ef9
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/7e0f0c9a2807aa920bd3ccc481a415b1aee8e9c2c206296e3e792954216c4bf8
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/5119e162e1e0
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/a06624d0447983dc91fa76c59ac5d87318a39d0aae8a23581553ec370b10e8b3
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/96439f3021c3
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/3cbca47a969eb19e5e4a6c976e75e4a6d157ece0ff1944ce8b047d05e9708fd0
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/f71f1e32f659
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/0b80696a534e851e24cd7daf9fafe5d6ade4ba11ae2fbc8e73f74aa4cc0c76b4
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/137e5c19fc1d
      Output information may be incomplete.
lsof: WARNING: can't stat() overlay file system /var/lib/docker/rootfs/overlayfs/08c9401eac98da4d005be55ca12980fe5e926fefb7b29943bd44e036a31acd94
      Output information may be incomplete.
lsof: WARNING: can't stat() nsfs file system /run/docker/netns/da75b81de49d
      Output information may be incomplete.
COMMAND    PID USER   FD      TYPE             DEVICE  SIZE/OFF      NODE NAME
claude  309690 mitu  cwd       DIR               8,48      4096   1733341 /tmp/fm-lab.jSM1vw/mate
claude  309690 mitu  rtd       DIR               8,48      4096         2 /
claude  309690 mitu  txt       REG               8,48 252755128   1731010 ~/.local/share/claude/versions/2.1.294
claude  309690 mitu  mem       REG               8,48   2129424   2416593 /usr/lib/x86_64-linux-gnu/libc.so.6
claude  309690 mitu  mem       REG               8,48    952616   2416619 /usr/lib/x86_64-linux-gnu/libm.so.6
claude  309690 mitu  mem       REG               8,48     14408   2416597 /usr/lib/x86_64-linux-gnu/libdl.so.2
claude  309690 mitu  mem       REG               8,48     14408   2416629 /usr/lib/x86_64-linux-gnu/libpthread.so.0
claude  309690 mitu  mem       REG               8,48     14624   2416631 /usr/lib/x86_64-linux-gnu/librt.so.1
claude  309690 mitu  mem       REG               8,48    236616   2416581 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
claude  309690 mitu    0u      CHR              136,0       0t0         3 /dev/pts/0
claude  309690 mitu    1u      CHR              136,0       0t0         3 /dev/pts/0
claude  309690 mitu    2u      CHR              136,0       0t0         3 /dev/pts/0
claude  309690 mitu    3u  a_inode               0,15         0      2060 [eventpoll:4,7,8,14,15,17,18,21,22,23,24,27,31,34,39,41,42,43,44,46,48]
claude  309690 mitu    4u  a_inode               0,15         0      2060 [eventfd:198]
claude  309690 mitu    5r      CHR                1,9       0t0         8 /dev/urandom
claude  309690 mitu    6r      REG               0,59         0 111942647 /proc/309690/statm
claude  309690 mitu    7r      CHR              136,0       0t0         3 /dev/pts/0
claude  309690 mitu    8u      CHR              136,0       0t0         3 /dev/pts/0
claude  309690 mitu    9u  a_inode               0,15         0      2060 [eventpoll:10,11,20,25,26,28,29,30,32,33,35]
claude  309690 mitu   10u  a_inode               0,15         0      2060 [eventfd:199]
claude  309690 mitu   11u     sock                0,8       0t0 111944247 protocol: TCP
claude  309690 mitu   12u  a_inode               0,15         0      2060 [eventpoll:13]
claude  309690 mitu   13u  a_inode               0,15         0      2060 [eventfd:200]
claude  309690 mitu   14u     unix 0x0000000000000000       0t0 111945209 /run/user/1000/cc-socks/309690.sock type=STREAM (LISTEN)
claude  309690 mitu   15u      CHR              136,0       0t0         3 /dev/pts/0
claude  309690 mitu   16w      CHR              136,0       0t0         3 /dev/pts/0
claude  309690 mitu   17u     sock                0,8       0t0 111947793 protocol: TCP
claude  309690 mitu   18u     sock                0,8       0t0 111947791 protocol: TCP
claude  309690 mitu   19r  a_inode               0,15         0      2060 inotify
claude  309690 mitu   20u     sock                0,8       0t0 111951260 protocol: TCP
claude  309690 mitu   21u     sock                0,8       0t0 111950227 protocol: TCP
claude  309690 mitu   22u     sock                0,8       0t0 111950228 protocol: TCP
claude  309690 mitu   23u     sock                0,8       0t0 111948549 protocol: TCP
claude  309690 mitu   24u     sock                0,8       0t0 111949713 protocol: TCP
claude  309690 mitu   25u     sock                0,8       0t0 111948696 protocol: TCP
claude  309690 mitu   26u     sock                0,8       0t0 111948698 protocol: TCP
claude  309690 mitu   27u     unix 0x0000000000000000       0t0 111948557 type=STREAM (CONNECTED)
claude  309690 mitu   28u     sock                0,8       0t0 111948700 protocol: TCP
claude  309690 mitu   29u     sock                0,8       0t0 111948702 protocol: TCP
claude  309690 mitu   30u     sock                0,8       0t0 111948704 protocol: TCP
claude  309690 mitu   31u     sock                0,8       0t0 111953219 protocol: TCP
claude  309690 mitu   32u     sock                0,8       0t0 111950480 protocol: TCP
claude  309690 mitu   33u     sock                0,8       0t0 111952349 protocol: TCP
claude  309690 mitu   34u     sock                0,8       0t0 111948550 protocol: TCP
claude  309690 mitu   35u     sock                0,8       0t0 112021947 protocol: TCP
claude  309690 mitu   39u  a_inode               0,15         0      2060 [pidfd:313272]
claude  309690 mitu   41u     unix 0x0000000000000000       0t0 111948558 type=STREAM (CONNECTED)
claude  309690 mitu   42u     unix 0x0000000000000000       0t0 111949701 type=STREAM (CONNECTED)
claude  309690 mitu   43u     unix 0x0000000000000000       0t0 111948560 type=STREAM (CONNECTED)
claude  309690 mitu   44u     unix 0x0000000000000000       0t0 111949702 type=STREAM (CONNECTED)
claude  309690 mitu   46u     unix 0x0000000000000000       0t0 111949704 type=STREAM (CONNECTED)
claude  309690 mitu   48u  a_inode               0,15         0      2060 [pidfd:313284]
Evidence: Relaunch ledger rows (attempt, wedge-capture, relaunched)

Source: Relaunch ledger rows (attempt, wedge-capture, relaunched)

1791475121 attempt 1791475122 wedge-capture /tmp/fm-lab.jSM1vw/state/.wedge-sample-smlab-1791475122.txt 1791475123 relaunched

1791475121	attempt
1791475122	wedge-capture	/tmp/fm-lab.jSM1vw/state/.wedge-sample-smlab-1791475122.txt
1791475123	relaunched
Evidence: Recorded relaunch call

Source: Recorded relaunch call

22:58:43 spawn argv=[smlab --secondmate] FM_SPAWN_NO_GUARD=1

22:58:43 spawn argv=[smlab --secondmate] FM_SPAWN_NO_GUARD=1
Evidence: Wedge alarm text

Source: Wedge alarm text

secondmate smlab was wedged (running, no progress); killed agent pid(s) 309690 and relaunched. Evidence: /tmp/fm-lab.jSM1vw/state/.wedge-sample-smlab-1791475122.txt

secondmate smlab was wedged (running, no progress); killed agent pid(s) 309690 and relaunched. Evidence: /tmp/fm-lab.jSM1vw/state/.wedge-sample-smlab-1791475122.txt
Evidence: Frozen lab pane before the full run (herdr shows working)

Source: Frozen lab pane before the full run (herdr shows working)


 ▐▛███▛█   Claude Code v2.1.294
▝▜██████▀  Haiku 5.5 · Claude Pro
 ▝▝   ▝▝   /tmp/fm-lab.jSM1vw/mate


❯ Write a very long, detailed 5000-word essay about the history of sailing ships, directly in your reply. Do not use
  any tools.


























· Coalescing… (6s · thinking)

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
❯
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← for agents
Evidence: Kill-scope: wedge_recover result and ledger

Source: Kill-scope: wedge_recover result and ledger

wedge_recover rc=0 capture=/tmp/fm-lab.DGwCrJ/state/.wedge-sample-smks-1791475296.txt killed=[878852] reason=[]
--- ledger
1791475297	wedge-capture	/tmp/fm-lab.DGwCrJ/state/.wedge-sample-smks-1791475296.txt
Evidence: Kill-scope: processes and pane after the kill

Source: Kill-scope: processes and pane after the kill

ps after kill (shell 865329, wrapper 878848, agent 878852, outside sentinel 868038) 865329 862634 Ss+ /usr/bin/zsh 868038 955 SN sleep 3600 herdr pane list after kill: {"pane_id":"w1:p1","agent_status":"unknown"} process-info: {"shell_pid":865329,"fg":[{"pid":865329,"name":"zsh"}]}

--- ps after kill (shell 865329, wrapper 878848, agent 878852, outside sentinel 868038)
    PID    PPID STAT COMMAND
 865329  862634 Ss+  /usr/bin/zsh
 868038     955 SN   sleep 3600
--- herdr pane list after kill
{"pane_id":"w1:p1","agent_status":"unknown"}
{"shell_pid":865329,"fg":[{"pid":865329,"name":"zsh"}]}
Evidence: Kill-scope: pids while frozen (claude in T state)

Source: Kill-scope: pids while frozen (claude in T state)

    PID    PPID STAT     TIME COMMAND
 865329  862634 Ss   00:00:02 /usr/bin/zsh
 868038     955 SN   00:00:00 sleep 3600
 878848  865329 S+   00:00:00 sh -c claude --model haiku --permission-mode bypassPermissions; true
 878852  878848 Tl+  00:00:04 claude --model haiku --permission-mode bypassPermissions
Evidence: Kill-scope: frozen pane render and pane after kill

Source: Kill-scope: frozen pane render and pane after kill

 ▐▛███▛█   Claude Code v2.1.294
▝▜██████▀  Haiku 5.5 · Claude Pro
 ▝▝   ▝▝   ~/.no-mistakes/worktrees/503155d0277b/01M4DYNNY01YTH2XNPY8VG01KE


❯ Write a 2000 word essay about the history of lighthouses. Do not use any tools.



























✢ Mulling… (3s · thinking)

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
❯ Killed

~/.no-mistakes/worktrees/503155d0277b/01M4DYNNY01YTH2XNPY8VG01KE @0477d5ae                                58s 23:01:37
❯
Evidence: Targeted test file output (40/40 ok)

Source: Targeted test file output (40/40 ok)

ok - fm_backend_tmux_agent_state: separates live, dead, missing, ambiguous, and unreadable
ok - fm_backend_tmux_agent_state: rejects malformed targets before probing tmux
ok - fm_backend_herdr_agent_state: preserves missing/no-agent/live/unknown husk behavior
ok - fm_backend_agent_state: routes tmux/Herdr and keeps Zellij unverified
ok - sweep: a confirmed-dead secondmate endpoint is killed and respawned
ok - sweep: an already-live secondmate is untouched and distinguishable in verbose diagnostics
ok - sweep: an authoritatively missing Pi secondmate window is relaunched
ok - sweep: an authoritatively missing pi-signed secondmate window is relaunched
ok - sweep: an existing ambiguous Pi process prevents duplicate recovery
ok - sweep: transient target unreadability never licenses recovery
ok - sweep: failed relaunch diagnostics distinguish a confidently missing endpoint
ok - sweep: an unverified harness blocks recovery with a concrete diagnostic
ok - sweep: idempotent by construction - a live secondmate is never re-touched on a later run
ok - sweep: skipped entirely under FM_BOOTSTRAP_DETECT_ONLY=1, exactly like the other mutating sweeps
ok - sweep: a silent no-op with no kind=secondmate meta present (a secondmate home's own natural scoping)
ok - sweep: a mate mid-episode under the shared liveness lock is skipped entirely
ok - sweep: an unreadable or unwritable relaunch ledger refuses to kill or spawn
ok - poll probe: remote states map to the same contract as local, one call each plus the alive wedge probe
ok - poll probe: unreachable or inconclusive remote reads preserve the route
ok - poll probe: a remote wedged verdict is relaunchable as wedged, and an unreachable wedge probe is not
ok - wedge recover: a remote mate is captured and killed only by its own host's control verb, and a failed call preserves it
ok - wedge: CPU time parses both the macOS and the Linux cputime format, and refuses anything else
ok - wedge: the configured no-progress window honors a floor, an off switch, and warns on a typo
ok - wedge: an unattributable agent process refuses the kill and preserves the endpoint
ok - wedge: wedged relaunches consume the existing bounded attempt budget, so a misdetection cannot kill-loop
ok - wedge: a working pane advancing either progress counter is never wedged
ok - wedge: a working pane whose CPU time rises is never wedged, however quiet its output
ok - wedge: a working pane running a long quiet tool command is never wedged
ok - wedge: an MCP server child is not mistaken for live work, so the frozen shape still wedges
ok - wedge: an unobserved gap (suspend or clock jump) restarts the window instead of killing live work
ok - wedge: the observed-sample gap widens with a slow watcher cadence instead of disabling recovery
ok - wedge: a macOS-format CPU time reading drives the verdict instead of reading unreadable
ok - wedge: a recorded CPU sample in an older unit restarts the window rather than reading unchanged
ok - wedge: a wedged verdict needs the configured no-progress window to elapse in full
ok - wedge: an idle, done, or blocked agent is never treated as wedged
ok - wedge: an unreadable counter, CPU time, or process table disables recovery instead of authorizing a kill
ok - wedge: the session-start sweep re-bases the window and never produces a verdict
ok - wedge: SIGKILL reaches only the pane's own agent pid, never its shell or a process outside the subtree
ok - wedge: recovery captures live-process evidence before the kill and records its path in the ledger
ok - probe: a wedged herdr secondmate is relaunchable on the watcher tick only, and an off window disables it
# all fm-secondmate-liveness tests passed
- Outcome: 🔧 2 issues found → auto-fixed → no changes applied ✅ across 3 runs (1h13m21s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • 🚨 bin/fm-herdr-wedge-lib.sh:195 - On macOS, wedge detection never fires, and macOS is the platform of the incident (mac-mini). BSD/macOS ps -o cputime= prints minutes:seconds.centiseconds, for example 0:12.34 or 125:03.45, not Linux's [dd-]hh:mm:ss. The awk parser splits on [:-] and requires every field to match ^[0-9]+$, so the 12.34 field fails it and fm_herdr_wedge_cpu_seconds returns nonzero. fm_herdr_wedge_classify then prints unreadable on every tick (line 339), so a frozen secondmate on the mac-mini is never recovered. This breaks the intent's core requirement and the user's decision to 'read CPU time with ps cputime on macOS'. fm-watch.sh also logs 'progress counters unreadable' for every mate on every tick. The tests run the real Linux ps, so they do not catch it. Fix: accept the BSD shape. Allow an optional fractional part on the last field (drop it, or compare with centisecond precision) and treat a 2-field M:SS value as minutes:seconds. Add a test that feeds a 0:12.34-style value through the FM_HERDR_PS_BIN seam.
  • ℹ️ bin/fm-herdr-wedge-lib.sh:90 - The 300s FM_HERDR_WEDGE_MAX_SAMPLE_GAP_SECS limit is fixed and does not depend on the watcher cadence. If a home sets FM_SECONDMATE_LIVENESS_SECS to 300 or more, or the watcher is often down for more than 5 minutes between wake cycles, every sample restarts the window and returns progressing. Wedge recovery is then turned off with no log line. This fails safe (no false kill), but it is the kind of silent degradation the unreadable log in fm-watch.sh was added to prevent. Consider deriving the gap from the tick cadence (for example, a few ticks), or logging when a window is restarted because of a gap.

🔧 Fix applied.
✅ Re-checked - no issues remain.

🔧 **Test** - 2 issues found → auto-fixed → no changes applied ✅
  • ⚠️ The headline end-to-end outcome was not observed live. A real frozen Claude reaching a wedged verdict after the 600s window, followed by capture, agent-only SIGKILL, pane close, ledger row and alarm, was not driven to completion. The lab driver was stopped after its first poll tick because this test phase had to end. Every precondition was shown live, but the kill and alarm rest on the colocated unit tests only. Re-run the lab driver for at least 11 minutes to close this gap.
  • ⚠️ live validation verdict: inconclusive (5 of 8 scenarios were driven live against the product); untested: After the full 600s window, the frozen mate gets a wedged verdict: evidence captured, ledger row written, only the agent pid SIGKILLed, the pane closed, relaunch invoked and the wedge alarm raised, macOS BSD cputime format (M:SS.CC) is parsed so detection works on the mac-mini, Remote secondmate wedge-state / wedge-recover over the fm-on.sh transport
  • Live validation: ⚠️ inconclusive - 5 of 8 scenarios driven live against the product
Scenario Result Live Evidence
A Claude secondmate frozen mid-turn (working, flat counters, flat CPU, no non-MCP children) keeps its no-progress window open across watcher ticks instead of being reset ✅ pass live driver-stdout.txt ticks 0-1: sm-frozen record since=1791470751 held at tick 1, seq 19, offset 0, cpu 200cs unchanged; pane1-frozen-before.txt
After the full 600s window, the frozen mate gets a wedged verdict: evidence captured, ledger row written, only the agent pid SIGKILLed, the pane closed, relaunch invoked and the wedge alarm raised ⏸️ untested no The 600s floor needs about 11 minutes of real ticks, and this test phase was forced to end about 1 minute in. Only unit coverage in tests/fm-secondmate-liveness.test.sh exists. Re-run the lab driver f…
Adversarial: a working Claude running a long silent foreground Bash command is never wedged (its CPU time grows, so the window restarts) ✅ pass live driver-stdout.txt: sm-busy cpu 2100cs -> 2400cs, since reset to 1791470812 at tick 1, verdict progressing; pane2-tree-before.txt shows the live perl tool child
Adversarial suspend guard: a gap between samples larger than the max gap restarts the window instead of counting unobserved time ✅ pass live suspend-gap-restart.txt: since stays 1791470769 after a 3s gap, restarts to 1791470781 after a 9s gap (max gap 5s)
Real MCP server children (evo-mcp-server.cjs under WSL /init) are not counted as live work, so a frozen agent still reads idle ✅ pass live fm_herdr_wedge_live_work on the live pane printed idle with two MCP children present; pane1-tree-before.txt
config/secondmate-wedge-window=off disables wedge recovery for a frozen working pane; values below the 600s floor and typos fall back to 900s with a warning ✅ pass live window-config-off-floor.txt
macOS BSD cputime format (M:SS.CC) is parsed so detection works on the mac-mini ⏸️ untested no The prior payload did not establish a live result: this host is Linux/WSL and no macOS host was available, so only the unit tests in tests/fm-secondmate-liveness.test.sh cover it. A live run needs a m…
Remote secondmate wedge-state / wedge-recover over the fm-on.sh transport ⏸️ untested no No reachable remote host such as mac-mini with a registered remote secondmate is available to this gate; that needs a configured remote route plus ssh access. Only unit coverage (poll probe remote wed…
  • bin/fm-herdr-lab.sh provision/run/teardown fm-lab-wedge-live-* with real claude --model haiku in two panes (default session untouched; tripwire held)
  • Froze pane 1's real Claude mid-turn with systemctl --user freeze &lt;scope&gt; and confirmed agent_status=working, frozen TUI timer, flat cputime, SIGTERM ignored
  • Pane 2: real Claude running a silent foreground ./build.sh (perl select 1500s) as a Bash tool call, agent_status=working
  • Driver sourcing bin/fm-secondmate-liveness-lib.sh: fm_secondmate_liveness_probe full sweep then poll ticks every 60s on both panes in a bin/fm-lab-home.sh lab home (stopped after tick 1)
  • fm_herdr_wedge_agent_pids, fm_herdr_wedge_live_work, fm_herdr_wedge_cpu_centiseconds, fm_herdr_wedge_counters against the live lab pane
  • fm_herdr_wedge_classify on the frozen pane with FM_HERDR_WEDGE_MAX_SAMPLE_GAP_SECS=5 (simulated suspend gap)
  • fm_secondmate_liveness_probe with config/secondmate-wedge-window=off, and fm_herdr_wedge_window with 60 and a typo
  • bash tests/fm-secondmate-liveness.test.sh (colocated wedge suite, rc=0)

🔧 Fix applied.
2 warnings still open:

  • ⚠️ The full live sequence is still not observed. Steps not seen live: wedged verdict after the 600s window, capture file, SIGKILL of only the agent pid, pane close, ledger row, relaunch call, wedge alarm. This round's lab setup worked: Claude was frozen by SIGSTOP inside a sh -c wrapper so zsh job control does not take back the foreground, and herdr kept reporting working. The driver recorded the baseline at tick 0. Then I stopped it by mistake about 50 seconds in, because a pkill -f pattern matched it. Re-run the same lab setup and leave the driver alone for 11 minutes or more to close this gap.
  • ⚠️ live validation verdict: inconclusive (1 of 4 scenarios were driven live against the product); untested: After the full 600s window the frozen mate is judged wedged: evidence captured, ledger row written, only the agent pid SIGKILLed, pane closed, relaunch invoked, wedge alarm raised, macOS BSD cputime and Linux cputime are parsed, so detection works on the mac-mini, Remote secondmate wedge-state and wedge-recover work over the fm-on.sh transport
  • Live validation: ⚠️ inconclusive - 1 of 4 scenarios driven live against the product
Scenario Result Live Evidence
A lab Claude frozen mid-turn (SIGSTOP, SIGTERM pending) stays herdr working, and the session-start sweep records a baseline instead of judging ✅ pass live final-full-window/driver-log.txt tick=0 wedge=baseline; pids-before.txt STAT Tl+
After the full 600s window the frozen mate is judged wedged: evidence captured, ledger row written, only the agent pid SIGKILLed, pane closed, relaunch invoked, wedge alarm raised ⏸️ untested no The test phase made an execution mistake, not a tool or permission problem: the driver was stopped too early. Re-run the lab driver uninterrupted for 11 minutes or more.
macOS BSD cputime and Linux cputime are parsed, so detection works on the mac-mini ⏸️ untested no The prior payload did not establish a live result: only the unit test in tests/fm-secondmate-liveness.test.sh passed. No macOS host was available; run the watcher on the mac-mini to verify live.
Remote secondmate wedge-state and wedge-recover work over the fm-on.sh transport ⏸️ untested no The prior payload did not establish a live result: only the stubbed transport tests passed. No real remote herdr host was authorized for testing; provide an authorized remote host to verify live.
  • bin/fm-herdr-lab.sh provision fm-lab-wedgefinal-3638766-936 plus bin/fm-lab-home.sh create (isolated lab session and home)
  • Started lab-only claude --model haiku in lab pane w1:p1 under sh -c, sent a long prompt, then kill -STOP and kill -TERM on its pid; herdr agent get still reported working
  • Lab driver that repeats the watcher's secondmate_liveness_tick against the lab meta; tick 0 baseline recorded, then the driver was stopped by mistake
  • bash tests/fm-secondmate-liveness.test.sh (40/40 ok)
  • bin/fm-herdr-lab.sh teardown fm-lab-wedgefinal-3638766-936 and rm -rf of the lab home

🔧 No changes applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 7 of 9 scenarios driven live against the product
Scenario Result Live Evidence
A frozen working Claude in a herdr pane gets no verdict before the 600s window, then gets a wedged verdict on the next watcher tick ✅ pass live last-attempt/driver-log.txt: ticks 0-10 report baseline/progressing with seq 3, offset 0 and cpu 600cs all flat; tick 11 (22:58:41, about 652s after the 22:47:49 baseline) reports status=relaunchable…
Recovery captures non-destructive evidence to a file in the secondmate state before the kill ✅ pass live last-attempt/wedge-capture.txt (from /tmp/fm-lab.jSM1vw/state/.wedge-sample-smlab-1791475122.txt) shows the process still in 'State: T (stopped)' at capture time. The kill-scope run did the same: kill…
SIGKILL lands only on the agent pid; the pane shell and a process outside the pane subtree survive ✅ pass live Full run: killed=[309690] (the claude pid), and the outside sentinel 366019 survived. Kill-scope run (killscope/ps-after-kill.txt): only claude 878852 was killed; pane shell zsh 865329 and outside pro…
After the kill, the relaunch path closes the agent-free pane ✅ pass live last-attempt/driver-log.txt tick 11: the post-relaunch herdr pane list returned panes:[] and the pane shell was gone from ps.
The relaunch ledger records the attempt, the capture path and the outcome ✅ pass live last-attempt/relaunch-ledger.txt: attempt, then wedge-capture <path>, then relaunched. Kill-scope run ledger: a wedge-capture row was written.
Relaunch goes through the existing guarded spawn path under the relaunch bound ✅ pass live last-attempt/spawn-calls.log: 'spawn argv=[smlab --secondmate] FM_SPAWN_NO_GUARD=1'. The driver checked recent_attempts=0 against max 3 before relaunching. The spawn is a lab fakeroot that only record…
The wedge alarm is raised so the recovery is never silent ✅ pass live last-attempt/alarm.log: 'secondmate smlab was wedged (running, no progress); killed agent pid(s) 309690 and relaunched. Evidence: ...'
macOS BSD cputime (M:SS.CC) is parsed, so detection works on the mac-mini ⏸️ untested no This host is Linux (WSL2) and no lab macOS host is available. The only Mac is the real mac-mini, which the user forbade touching. To drive it live, provide a disposable macOS lab host with herdr and C…
Remote secondmate: wedge-state and wedge-recover over the fm-on.sh transport, with no cross-host signalling ⏸️ untested no No disposable remote lab host exists, and the user told me not to use a real remote host. To drive it live, provide an SSH-reachable lab host registered as a throwaway remote endpoint.
  • Full 12-minute lab run (driver started detached and left alone): last-attempt/driver.sh replays the fm-watch.sh liveness tick (probe -> relaunch bound -> fm_secondmate_liveness_relaunch) every 60s against lab pane fm-lab-wedge12m-301279-3492:w1:p1, with a lab-only claude frozen by SIGSTOP inside sh -c. The fm-spawn.sh it calls is a lab fakeroot that only records the call.
  • Kill-scope lab: bin/fm-herdr-lab.sh provision fm-lab-killscope-862559-30509, pane run w1:p1 &#34;sh -c &#39;claude --model haiku --permission-mode bypassPermissions; true&#39;&#34;, prompt sent, kill -STOP &lt;recorded claude pid&gt;, then fm_secondmate_liveness_wedge_recover called directly on a lab meta under a marked lab home from bin/fm-lab-home.sh create
  • Post-kill checks: ps -o pid,ppid,stat,args -p &lt;shell&gt;,&lt;wrapper&gt;,&lt;agent&gt;,&lt;outside sentinel&gt;, herdr pane list, herdr pane process-info --pane w1:p1, herdr pane read w1:p1
  • Teardown only by recorded pid and exact session name: kill 868038, bin/fm-herdr-lab.sh teardown fm-lab-killscope-862559-30509, rm -rf $LAB. Afterwards I confirmed the default and fm-remote sessions were unchanged.
  • bash tests/fm-secondmate-liveness.test.sh (40/40 ok, including BSD and Linux cputime parsing and the stubbed remote wedge-state and wedge-recover cases)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

A Claude Code secondmate on the Mac mini froze three times, each time
needing a hand-run SIGKILL before it could be relaunched. The liveness
library already probes every secondmate endpoint on the watcher tick and
relaunches a dead one through the guarded spawn path, but a frozen agent
classifies `alive` - correctly and permanently, since its process really
is running - so it fell through every recovery path.

Add `wedged` as a separate state for herdr-backed secondmates, computed
from the two progress counters herdr already exposes (state_change_seq
and scroll.max_offset_from_bottom). Both must stay unchanged for the
whole configured window while the agent still reports `working`, which is
the machine-readable form of the stopped TUI timer a human recognizes.

Recovery captures non-destructive evidence first and records its path in
the relaunch ledger - three freezes left no sample, which is why the
mechanism behind them is still unproven - then SIGKILLs only pids that
classify as a harness inside the pane's own process subtree, relaunches
through the existing path, and raises the wedge alarm so an automatic
kill of live work is never silent.

Three limits guard against killing a legitimately quiet agent: both
counters must be frozen, the window is configurable with a 15-minute
default and a 10-minute floor, and the existing per-mate relaunch bound
applies unchanged. An unreadable counter yields `unreadable`, never
`wedged`, so a vendor shape change can only disable recovery; the watcher
logs that case rather than letting it degrade silently.

For a remote mate the counters, the capture, and the kill all run on its
own host through two new control verbs; the parent never signals across
hosts or derives a pid from a local reading.

Supporting changes: extract the wedge alarm's channel dispatch from
fm-supervise-daemon.sh into bin/fm-wedge-alarm-lib.sh so both callers
share one notification path, and move the herdr adapter's pane
process-tree walk into bin/fm-agent-process-lib.sh so the probe that
proves a pane agent-free and the kill that acts on it cannot drift.

Out of scope, noted as follow-ups rather than built: ordinary crewmates,
non-herdr backends, the Stop hook's asyncRewake timeout, and holding a
steer until the pane is idle.
… wedge kill

Review of the wedged-secondmate recovery found three ways the detector could
SIGKILL a healthy agent. All three are fixed here; the first carries the
captain's explicit decision on detection policy.

Two frozen progress counters do not separate a deadlock from a long, quiet
turn. A healthy agent running one long Bash command or a subagent redraws its
spinner in place and adds no scrollback, so neither counter moves and it would
have been killed up to the relaunch bound. Require two further signals before
a wedged verdict, both from the incident's own evidence that the frozen process
sat at 0% CPU with no children: the agent's consumed CPU time must not grow
across the window, and the agent must have no live child process other than its
own MCP servers. Either signal unreadable now yields no verdict, so an
unreadable process table can only disable recovery, never authorize a kill.

The window was wall-clock age, not observed time. A machine that slept for 20
minutes with an agent mid-turn woke up holding a pre-sleep sample whose counters
could not have moved, and only the session-start sweep re-based the window, so
the first poll after wake would have killed live work. The record now carries
the previous sample's own epoch and a gap larger than the detector could have
watched restarts the window. The comment claiming suspend was already covered
was wrong and is corrected.

Local wedge recovery leaked a husk pane. The kill deliberately spares the pane
shell, but the relaunch then spawns a new pane exactly as it does for a dead
endpoint, so the agent-free pane was left behind. Close it in the relaunch path
once nothing is running in it, rather than in the kill primitive, so the
agent-only kill scope and the tests that prove it stay intact. A remote route
needs nothing: its host-local launch already removes a confirmed agent-less
endpoint.

Tests cover each new signal with its own control on the same fixture, against
real processes: a live tool child stays alive while an MCP-server child alone
still wedges, rising CPU time stays alive, an unobserved gap restarts the window
while continuously observed frozen time still wedges, and an unreadable counter,
CPU column, or process table never wedges.
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: thanks @mitu1306. Three hard freezes that each needed a manual kill -9 is a real pain, and the care here shows: four independent frozen signals, observed-time-only windows, unreadable means no verdict, kill scope limited to the pane's own harness subtree, evidence captured first, the existing relaunch bound, and a wedge alarm on every recovery.

I compared head 2972763d against main fb75c1f9. The no-mistakes attestation matches this head. It composes textually with main's #6920; one small ask is to gate the agent get / pane list wedge probes on fm_backend_herdr_target_observable like the other passive Herdr reads, so they never start a server.

CI: I reviewed the diff first (no workflow or secret surface; base64 appears only in the test's fake ssh), then approved CI 37806088942 and Require no-mistakes 37806088913.

Contract-class: new-default. With no config the window is 900s, so every Herdr secondmate gets automatic SIGKILL-and-relaunch unless the captain writes off.

VISION.md, rule by rule

  • One captain, one interface: aligns (the alarm keeps recovery visible).
  • Authority is explicit and never inferred: does not align as shipped. New capability "ships as an option to enable", and killing a process is destructive.
  • Scripts own the mechanics: aligns.
  • A restart is a non-event: aligns.
  • Delegation with a spine: cannot tell. Live is inconclusive (5 of 8); the full-window chain, macOS cputime, and the remote verbs weren't driven live.
  • The fleet outlives any vendor: aligns. It's Herdr-scoped and reads exposed counters.
  • Scope: aligns.

Waiting on you, the author: could you make it opt-in (absent config/secondmate-wedge-window = off; a number enables it, with the 600s floor), then re-raise through no-mistakes with a live run of the full window on the mac-mini (verdict, evidence, agent-only kill, relaunch, alarm)? With those two changes it can auto-merge once green. Thank you, no rush.

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.

2 participants