Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ for test_script in tests/*.test.sh; do bash "$test_script"; done # behavior te
tests/fm-wake-queue.test.sh # durable wake queue losslessness, catch-up, double-drain, duplicate-collapse, and drain liveness guard tests
tests/fm-watcher-lock.test.sh # watcher singleton, lock-race, watch-arm liveness, and guard-warning tests
tests/fm-turnend-guard.test.sh # shared supervision predicate plus Claude Stop-hook scoping, loop guard, fail-open, and live watcher health tests
tests/fm-watch-triage.test.sh # always-on watcher triage: benign absorb, actionable surface, stale status-log override, wedge threshold, heartbeat backstop, and afk one-shot coherence
tests/fm-watch-triage.test.sh # always-on watcher triage: benign absorb, actionable surface, stale status-log override, herdr semantic stale dedup, wedge threshold, heartbeat backstop, and afk one-shot coherence
tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, and fm-send submit tests
tests/fm-send-settle.test.sh # fm-send post-submit settle pause, tuning, disable, and --key bypass tests
tests/fm-send-popup-settle.test.sh # fm-send pre-Enter popup-settle selection for slash commands and codex $skill invocations
Expand Down
69 changes: 52 additions & 17 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -158,23 +158,52 @@ hash_pane() {
if command -v md5 >/dev/null 2>&1; then md5 -q; else md5sum | cut -d' ' -f1; fi
}

# window_is_busy: 0 (busy) iff the task's harness is actively working. Prefers
# a backend's native semantic busy state (fm_backend_busy_state - herdr's
# agent.get; herdr-addendum "busy state" row, "the first backend where
# fm_session_busy_state gets real semantics"); falls back to the existing
# pane-tail regex ONLY when the backend reports unknown (tmux always does, so
# its path is unchanged byte-for-byte). <tail40> is the same bounded capture
# already read for hashing, so this adds no extra backend calls on the
# regex-fallback path.
window_is_busy() { # <window> <tail40>
local w=$1 tail40=$2 bs
bs=$(fm_backend_busy_state "$(window_backend "$w")" "$w" 2>/dev/null)
case "$bs" in
# busy_state_is_busy: 0 (busy) iff the task's harness is actively working, from
# an ALREADY-RESOLVED backend busy state <bs> (busy|idle|unknown) plus the
# <tail40> capture. A backend that reports a semantic state (herdr's agent.get -
# the herdr-addendum "busy state" row) decides directly; unknown (tmux always,
# and herdr when it cannot read the agent) falls back to the last 6 non-blank
# lines of the pane tail matched against the busy regex, so the tmux path is
# byte-identical. The stale loop resolves the backend busy state ONCE and shares
# it with stale_signal below, so herdr's agent.get is not called twice per pane
# per poll.
busy_state_is_busy() { # <bs> <tail40>
case "$1" in
busy) return 0 ;;
idle) return 1 ;;
*)
printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -6 | grep -qiE "$BUSY_REGEX"
printf '%s' "$2" | grep -v '^[[:space:]]*$' | tail -6 | grep -qiE "$BUSY_REGEX"
;;
esac
}

# stale_signal: the value the stale-dedup logic hashes for a window given its
# ALREADY-RESOLVED backend busy state <bs> and <tail40> capture. This is the
# herdr stale-echo-churn fix. On a backend with a native SETTLED agent state
# (herdr reports idle for idle/done/blocked), the signal is that semantic state,
# NOT the pane content - so a settled pane that merely REPAINTS its volatile
# harness UI (claude's timer-driven recap line, rotating tips, animated
# "for Xm Ys" summaries, transient slash-completion menus) keeps ONE signal and
# is surfaced once, instead of minting a fresh stale hash every few minutes.
# An idle verdict is treated as settled only when the same last-6-nonblank-line
# BUSY_REGEX corroboration used by the fallback path does not see the busy banner,
# because docs/herdr-backend.md records that herdr can report idle during a long
# foreground tool call while the pane still renders "esc to interrupt".
# Content normalization cannot fix repaint churn robustly, because volatile rows
# also shift a variable-length prefix through the tail-N hash window. For tmux
# (busy state always unknown), busy/unknown herdr panes, and idle herdr panes
# that still look busy, the signal is the pane-content hash exactly as before,
# so the proven tmux path is unchanged byte-for-byte.
stale_signal() { # <bs> <tail40>
case "$1" in
idle)
if printf '%s' "$2" | grep -v '^[[:space:]]*$' | tail -6 | grep -qiE "$BUSY_REGEX"; then
printf '%s' "$2" | hash_pane
else
printf 'agentstate:idle'
fi
;;
*) printf '%s' "$2" | hash_pane ;;
esac
}

Expand Down Expand Up @@ -460,8 +489,14 @@ EOF
# A secondmate idling on its own watcher is healthy. Its parent supervises
# it through status writes and heartbeats, not pane-idle staleness.
[ "$(window_kind "$w")" = secondmate ] && continue
tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue
h=$(printf '%s' "$tail40" | hash_pane)
backend=$(window_backend "$w")
tail40=$(fm_backend_capture "$backend" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue
# Resolve the backend busy state ONCE and derive the stale-dedup signal from
# it: for a settled herdr pane the signal is the semantic agent state (so
# volatile-UI repaints do not churn a new stale hash), else the pane-content
# hash exactly as before (tmux path unchanged).
bs=$(fm_backend_busy_state "$backend" "$w" 2>/dev/null)
h=$(stale_signal "$bs" "$tail40")
key=$(printf '%s' "$w" | tr ':/.' '___')
hf="$STATE/.hash-$key"
cf="$STATE/.count-$key"
Expand All @@ -475,7 +510,7 @@ EOF
# else the last 6 non-blank lines only (the TUI footer area, where every
# verified harness renders its busy indicator) so busy-looking strings
# in displayed content cannot suppress stale detection.
if [ "$n" -ge 2 ] && ! window_is_busy "$w" "$tail40"; then
if [ "$n" -ge 2 ] && ! busy_state_is_busy "$bs" "$tail40"; then
# The pane is idle/stale at hash $h. Triage decides whether this wakes
# firstmate. Detection itself is unchanged from above.
if afk_present; then
Expand Down Expand Up @@ -557,7 +592,7 @@ EOF
else
printf '%s' "$h" > "$hf"
echo 0 > "$cf"
# Pane content changed: the crew is active again, so reset the escalation timer.
rm -f "$sf"
rm -f "$ssf"
fi
done < <(recorded_windows)
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ New spawns select a backend from `--backend`, then `FM_BACKEND`, then local `con
Runtime auto-detection is innermost-first: `$TMUX` wins over `HERDR_ENV=1`, which wins over cmux's primary `CMUX_WORKSPACE_ID` marker and documented fallback signals; auto-detected herdr or cmux prints a one-time opt-out notice, auto-detected tmux stays silent, and zellij and orca are never auto-detected (only explicit selection).
Unknown backend names fail loudly.
For compatibility, default tmux tasks do not write `backend=tmux`; every reader treats a missing `backend=` field as `tmux`.
`fm-watch.sh` polls each window's backend for a busy state: tmux, zellij, orca, and cmux have no native primitive and always report unknown, preserving the original pane-tail-regex detection unchanged; herdr's `agent.get` semantic state (working/idle/done/blocked) is consulted first for stale detection, with unknown native states falling back to the same regex.
`fm-watch.sh` polls each window's backend for a busy state: tmux, zellij, orca, and cmux have no native primitive and always report unknown, preserving the original pane-tail-regex detection unchanged.
Herdr's `agent.get` semantic state (working/idle/done/blocked) is consulted first; a native `busy` verdict suppresses stale detection, a settled `idle` verdict with no rendered busy banner dedupes stale wakes on the semantic `agentstate:idle` signal instead of volatile pane content, and unknown or still-busy-looking panes fall back to the same pane hash and regex path as tmux.
That poll loop is the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite.
Herdr is experimental and can be selected explicitly or by runtime auto-detection: treehouse remains the worktree provider for it exactly as it is for tmux (herdr is a session provider only), and its full verification - the container shape decision, created-vs-adopted default-tab prune safety, restored-layout husk respawn idempotency, verified CLI facts, a verified small-`--lines` capture bug and its workaround, and known gaps - is recorded in `docs/herdr-backend.md`.
Herdr's container shape is workspace-per-home plus tab-per-task: the primary home uses workspace label `firstmate`, secondmate homes use `2ndmate-<secondmate-id>`, and recovery/list-live scopes to the current `FM_HOME`'s workspace.
Expand Down
13 changes: 13 additions & 0 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,19 @@ Together, those gaps let a genuinely still-working herdr crew read as not provab
The cross-branch attribution fallback now uses the real `no-mistakes runs` command, and the watcher checks provably-working evidence before a stale status-log verb can make a stale pane terminal.
This does not mask a genuinely human-blocked agent (a permission dialog, not mid-tool-call): that pane does not render the busy banner, so the corroboration still correctly reports not-busy for it.

## Watcher stale-dedup: semantic signal for a settled pane

`bin/fm-watch.sh`'s stale detection hashes a bounded pane capture and surfaces a fresh wake whenever a settled pane produces a new stable hash.
On herdr this churned: an idle or done `claude` pane repaints timer-driven volatile UI even when the crew has not changed - the `β€» recap:` line toggling on and off, rotating `Tip:` lines, the animated `✻ …ed for Xm Ys` summary, and transient slash-completion menus - so the capture's hash flips every few minutes and re-wakes the supervisor for a crew that is genuinely done.
Verified live against the real herdr 0.7.1 backend on 2026-07-05 by capturing `herdr pane read --source recent --lines 200` for every workspace pane every 20 seconds for ~7 minutes and correlating with each pane's `agent get` state: a `done` pane produced 4 distinct hashes over the window and an `idle` pane 8, each one a spurious stale re-wake, while a plain post-exit shell pane stayed at 1.
Line-stripping the volatile rows before hashing does not converge, because those rows also change the total line count, which shifts the tail-N hash window over a variable-length prefix (measured: stripping still left 3 distinct hashes across 4 `done`-pane samples).

The fix keys the stale-dedup signal on the native SETTLED agent state instead of the pane content, but only after corroborating that the idle pane no longer renders the busy banner described in the 2026-07-02 gap above.
When `fm_backend_busy_state` reports `idle` (herdr's `idle`/`done`/`blocked`, per the "Busy state" row above) and the last 6 non-blank pane lines do not match `BUSY_REGEX`, the watcher hashes the constant `agentstate:idle` rather than the capture, so a settled pane that merely repaints keeps one signal and is surfaced once; a genuine state change still mints a new signal and surfaces once.
tmux (busy state always `unknown`), a herdr pane whose state is `working`/`unknown`, and an `idle` herdr pane that still displays the busy banner keep the pane-content hash exactly as before, so the proven default path is unchanged byte-for-byte.
The same resolved busy state feeds both this signal and the existing busy-suppression check, so herdr's `agent.get` is read once per pane per poll, not twice.
This is a distinct axis from the 2026-07-02 incident above (a still-working crew misread as not-working); here a correctly-settled crew was surfaced repeatedly rather than once.

## Slash/`$` autocomplete popup hazard (confirmed, same mitigation as tmux)

Typing `/mem` into a live `claude` composer inside a herdr pane and reading the pane back within 0.1 seconds already shows the full autocomplete popup.
Expand Down
10 changes: 6 additions & 4 deletions tests/fm-session-start.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,9 @@ EOF
make_fake_toolchain "$fakebin"
make_fake_ps_claude "$fakebin"
# Force a MISSING diagnostic line so the bootstrap section is non-trivial.
rm -f "$fakebin/node"
# Use a firstmate wrapper that exists only in fakebin; node may exist in the
# fixed BASE_PATH on developer and CI hosts.
rm -f "$fakebin/gh-axi"

printf 'window=fm-sess:w1\nkind=ship\n' > "$home/state/task-a.meta"

Expand All @@ -280,7 +282,7 @@ EOF
[ "$context_line" -lt "$fleet_line" ] || fail "CONTEXT did not precede FLEET STATE"
[ "$fleet_line" -lt "$next_line" ] || fail "FLEET STATE did not precede NEXT STEP"

missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: node' | head -1 | cut -d: -f1)
missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: gh-axi' | head -1 | cut -d: -f1)
[ -n "$missing_line" ] || fail "MISSING diagnostic did not appear at all"
[ "$missing_line" -lt "$fleet_line" ] || fail "actionable MISSING diagnostic was buried after the bulk fleet-state digest"

Expand Down Expand Up @@ -400,7 +402,7 @@ $rec
EOF
make_fake_toolchain "$fakebin"
make_fake_ps_claude "$fakebin"
rm -f "$fakebin/node"
rm -f "$fakebin/gh-axi"

append_wake "$home/state" signal task-z "needs-decision: pick a library"

Expand All @@ -409,7 +411,7 @@ EOF
# fm-lock.sh's own exact success text.
assert_contains "$out" "lock acquired: harness pid" "fm-lock.sh's real output did not appear (composition, not reimplementation)"
# fm-bootstrap.sh's own exact MISSING-tool line format.
assert_contains "$out" "MISSING: node (install:" "fm-bootstrap.sh's real detect line did not appear verbatim"
assert_contains "$out" "MISSING: gh-axi (install:" "fm-bootstrap.sh's real detect line did not appear verbatim"
# fm-wake-drain.sh's real drained record (raw tab-separated queue line).
assert_contains "$out" "$(printf 'signal\ttask-z\tneeds-decision: pick a library')" "fm-wake-drain.sh's real drained record did not appear"

Expand Down
Loading