fix(tmux): resolve a target before probing it so liveness stops answering for another window - #80
Merged
Merged
Conversation
added 3 commits
August 5, 2026 14:00
…ering for another window `tmux display-message -p -t <target>` does not refuse a target that does not resolve. It answers for a different window - the target session's current window, or the active client's - and returns 0 in every case. Both tmux liveness probes were built on that exit status, so their verdicts described the supervising pane and never touched the target at all. Reported by Tugboat 2026-07-28 and reproduced on tmux 3.4 (2026-08-05): three invented window names all returned a real pane id belonging to someone else. `fm_backend_target_exists` therefore reported a non-existent window as present, and `fm_backend_agent_alive` computed its confident alive/dead verdict from whatever the fallback pane happened to be running - so the same invented name read ALIVE on a host whose fallback pane ran claude and DEAD on one whose fallback pane ran bash. That host-dependence is why a fleet-wide defect looked like a local environment quirk. The secondmate-liveness sweep gates a respawn on `dead` only, so that gate could be satisfied or starved for reasons unrelated to the target: a dead secondmate never respawned on one host, a live one at risk of duplication on another. `tmux list-panes -t` refuses every invented shape (`sess:no-such-window`, `sess:@9999`, `sess:win.99`, `%9999`, `@9999`, bare unknown names) and resolves every real one, so it needs no target-shape parsing of our own. `fm_tmux_resolve_pane` (bin/fm-tmux-lib.sh) is now the one gate every read of a caller-supplied target passes; callers read the pane id it returns, which is exact rather than subject to tmux's own prefix matching. An unresolvable target reports unknown rather than dead: whether the endpoint exists at all is `fm_backend_target_exists`'s question, and the sweep turns unknown into a reported skip instead of a silent wrong verdict. The hazard was already documented in a comment beside bin/fm-spawn.sh's worktree poll while two other call sites kept the unguarded form, so the rule is now enforced by a test rather than by a comment: no bin/ script may read `display-message -p -t` against an unresolved caller-supplied target, and the rule self-checks that it still detects a known offender. Gated: fm_backend_tmux_current_command (and so agent_alive), fm_backend_tmux_current_path, fm_backend_tmux_send_key's preflight, fm_backend_target_exists's tmux branch, fm-crew-state.sh's pane_readable, fm_tmux_composer_state, and fm-context-reset.sh's own-pane read - the last because its "could not be resolved" refusal could not fire, in the one script that types a reset into whatever the target names. Measured, not assumed: capture-pane and send-keys refuse an unresolvable target correctly, so no pane content was ever misread and no keystroke misdelivered. The other four backends were read for the same pattern and are not affected - herdr, zellij, orca, and cmux each address by id through an API that errors on an unknown one, and herdr additionally round-trips the echoed pane_id. Two adjacent tmux sites are recorded rather than changed, because neither was measured to answer wrongly here: container_ensure's bare `-p` read of its own current window, and the away-mode status-line flash. Tests: the real-tmux control drives BOTH fallback shapes on a private socket, including an agent-named binary in the fallback pane - the exact case that produced the false ALIVE. It fails against the pre-fix code with that symptom.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix a fleet-wide defect in firstmate's shared tracked code: the tmux liveness probes answer for the WRONG window, so both presence and agent-alive verdicts can describe a target that does not exist.
Root cause, reported by Tugboat 2026-07-28 and reproduced on tmux 3.4 on 2026-08-05: 'tmux display-message -p -t ' does NOT refuse a target that does not resolve. It answers for a different window (the target session's current window, or the active client's) or expands the format to empty, and returns 0 in every one of those cases. So fm_backend_target_exists reported a non-existent window as PRESENT, and fm_backend_agent_alive computed its confident alive/dead verdict from whatever the supervising pane happened to be running. The same invented window name read ALIVE on a host whose fallback pane ran claude and DEAD on a host whose fallback pane ran bash - neither is a reading of the target. This matters because bin/fm-bootstrap.sh's secondmate-liveness sweep gates a respawn on 'dead' ONLY, deliberately, so that gate could be satisfied or starved for reasons unrelated to the target: a dead secondmate never respawned on one host, or a live one at risk of duplication on another.
Deliberate decisions made while doing this work, so they are not mistakes:
Constraints followed: this is firstmate's shared tracked material, so .agents/skills/firstmate-coding-guidelines/SKILL.md applies - one sentence per line in tracked Markdown, plain dash never an em dash, no agent co-author, shellcheck-clean bin scripts via bin/fm-lint.sh, colocated tests named .test.sh extending the existing runner. AGENTS.md was deliberately NOT touched: by that skill's knowledge-placement decision tree this is mechanism and reference detail, so it belongs in docs/ and script headers, not in the always-loaded surface whose token cost every session pays.
Validation already run locally: bin/fm-lint.sh clean, and bin/fm-test-run.sh --all green at 127 scripts, 0 failed, 19 gate-skips, matching the pre-change baseline's 19.
What Changed
fm_tmux_resolve_paneinbin/fm-tmux-lib.sh, a shared gate built ontmux list-panes -tbecause that command refuses invented target shapes whiledisplay-message -p -tsilently answers for a different window and still exits 0. Probes now read the resolved pane id instead of the caller's target string, and an unresolvable target reportsunknownrather thandead, so the secondmate-liveness sweep skips it instead of respawning.fm_backend_target_exists,fm_backend_agent_alive,fm_backend_tmux_current_pathandfm_backend_tmux_send_key's preflight inbin/backends/tmux.sh, plusfm_tmux_composer_state,fm-crew-state.sh'spane_readable, andbin/fm-context-reset.sh's own-pane read. The other four backends address by id through APIs that error on unknown ids and were left unchanged; that survey and the two deliberately unchanged tmux sites are recorded inbin/fm-backend.sh's header anddocs/tmux-backend.md.tests/fm-tmux-target-resolve.test.sh, which forbids anybin/script from readingdisplay-message -p -tagainst an unresolved target and self-checks that the rule still detects a known offender, andtests/fm-backend-tmux-smoke.test.sh, which drives both fallback shapes against a real tmux server on a private socket and was verified to fail against the pre-fix code. Roughly 25 existing suites gained alist-panescase in their fake-tmux stub; six modelled dead or absent endpoints conditionally and had those conditions mirrored onto the pane id.Risk Assessment
✅ Low: All three round-1 findings are correctly fixed and independently verified against real tmux, the resolver now returns the pane the target names while preserving every required refusal property, and the enforcement rule and smoke control are strengthened with regression coverage that would fail the prior defect.
Testing
Ran the two new suites (
fm-tmux-target-resolve,fm-backend-tmux-smoke) plus thirteen affected existing suites - all green, no failures or gate skips - then produced the evidence the unit tests alone cannot: an end-to-end transcript driving the realbin/fm-bootstrap.shsecondmate-liveness sweep against a real tmux 3.4 server on a private socket, base commit versus fixed commit, on both host shapes from the incident report. The base code reads a non-existent secondmate window as alive/PRESENT on a claude-running host and dead/PRESENT on a bare-shell host and either never respawns or respawns against nothing; the fixed code reads unknown/ABSENT on both and the sweep reports a skip without respawning, while live endpoints still read alive and dead correctly. I additionally ran the new enforcement rule against the pre-fixbin/, where it flags all seven unguardeddisplay-message -p -tcall sites and none on the fixed tree. This change is shell/CLI only with no rendered UI surface, so CLI transcripts are the end-user-visible artifact; no screenshot applies. Transient artifacts (base checkout, private tmux sockets) were removed and the worktree is clean.Evidence: End-to-end: bootstrap secondmate-liveness sweep, base vs fixed, on both host shapes (real tmux 3.4)
Evidence: Reproducer script for the end-to-end transcript above
Evidence: The new enforcement rule run against the pre-fix bin/ (it flags all 7 real offenders) and the fixed bin/ (none)
The enforcement rule from tests/fm-tmux-target-resolve.test.sh, run against the PRE-FIX bin/ (base commit 7778ca3). Every line below is a real call site that read tmux display-message against an unresolved caller-supplied target. bin/backends/tmux.sh:45: tmux display-message -p -t "$1" '#{pane_id}' >/dev/null bin/backends/tmux.sh:101: tmux display-message -p -t "$1" '#{pane_current_path}' 2>/dev/null bin/backends/tmux.sh:137: tmux display-message -p -t "$1" '#{pane_current_command}' 2>/dev/null bin/fm-backend.sh:673: tmux display-message -p -t "$target" '#{pane_id}' >/dev/null 2>&1 bin/fm-context-reset.sh:209:TARGET=$(tmux display-message -p -t "$PANE" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null || true) bin/fm-crew-state.sh:247: tmux) tmux display-message -p -t "$1" '#{pane_id}' >/dev/null 2>&1 ;; bin/fm-tmux-lib.sh:104: cy=$(tmux display-message -p -t "$target" '#{cursor_y}' 2>/dev/null) || { printf 'unknown'; return 0; } --- the same rule against the FIXED bin/ (ca002c3) --- (no offenders)Evidence: Verdict matrix distilled from the end-to-end transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 4 issues found → auto-fixed ✅
bin/fm-tmux-lib.sh:116- fm_tmux_resolve_pane resolves a pane-qualified target to the window's ACTIVE pane, not the pane the target names. Verified on tmux 3.4 with a private socket:tmux list-panes -t %0(an inactive pane) andtmux list-panes -t sess:win.0both list every pane of the containing window (%0 active=0, %1 active=1), because list-panes takes a target-WINDOW; the loop at lines 113-119 then prefers pane_active=1. The pre-change form was exact:display-message -p -t %0 '#{pane_id}'returns %0. Two reachable paths: (1) bin/fm-context-reset.sh:209 takes PANE=$TMUX_PANE (a %N pane id), lines 216-218 derive TARGET from RESOLVED_PANE, and line 244 types/clearinto TARGET - in any split window where firstmate's own pane is not the active one, the reset is typed into a neighbouring pane, defeating that script's stated premise that a reset must never be typed into a pane it cannot identify. (2) bin/fm-supervise-daemon.sh resolves its supervisor target from $TMUX_PANE and calls fm_backend_composer_state -> fm_tmux_composer_state (bin/fm-tmux-lib.sh:166-169), which now reads cursor_y and capture-pane from the active pane while the injection still goes to the original %N, decoupling the composer-emptiness safety check from the pane being typed into. This is the same answer-for-the-wrong-pane class the change exists to close, narrowed from window scope to pane scope. Flagged ask-user because the repair collides with deliberate decision 1 (resolver is list-panes-only and parses no target shapes) and with tests/fm-tmux-target-resolve.test.sh:244, which asserts the resolver body contains nodisplay-message. Suggested repair: keep list-panes as the refusal gate, then readdisplay-message -p -t "$target" '#{pane_id}'(it cannot fall back once list-panes has proven the target resolves) and verify the returned id appears in the listing; relax that assertion to "list-panes gates before any read".tests/fm-backend-tmux-smoke.test.sh:225- The bare-shell half of the required two-shape control passes the literalbashas the expected fallback command, buttmux new-window -d -t $SESSION -n shell-fallbackstarts the host's default shell. On a zsh host (macOS default, or any user with a non-bash login shell) line 202 reads 'zsh' and the precondition at 203-204 fails on correct code, in the one suite that proves the fix against real tmux. fm_backend_tmux_agent_alive already classifies zsh/fish/dash as dead (bin/backends/tmux.sh:218), so only the literal is wrong: read the fallback window's own#{pane_current_command}after creating it and pass that as expected_cmd.tests/fm-tmux-target-resolve.test.sh:207- The anti-recurrence rule exempts anydisplay-message -p -twhose argument is spelled "$pane", "$PANE", "$RESOLVED_PANE" or "$TMUX_PANE", so the guard is satisfied by variable naming rather than by actual resolution. A futurepane=$1; tmux display-message -p -t "$pane" '#{pane_current_command}'- an unresolved caller-supplied target, the exact incident shape - is filtered out at line 208 and the test passes. The self-check only proves the rule fires on the$targetspelling. Requiring that the exempt variable is assigned from fm_tmux_resolve_pane (or from $TMUX_PANE) in the same file would make the rule match its stated contract.tests/fm-backend-tmux-smoke.test.sh:203- The control hard-asserts that tmux still exhibits the display-message fallback. If a future tmux refuses the invented target, raw is empty and the suite fails even though firstmate's code is correct. The test's own comment records this as deliberate (assert rather than assume, to avoid a vacuous pass); noted as an acknowledged tradeoff, not a defect. A skip-with-reason on a non-fallback tmux would preserve both properties.🔧 Fix: resolve a target to the pane it names, not the active one
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bin/fm-test-run.sh tests/fm-tmux-target-resolve.test.sh tests/fm-backend-tmux-smoke.test.sh- the two new suites, including the real-tmux smoke control on a private socket (14 + 11 assertions, all pass)bin/fm-test-run.sh tests/fm-backend.test.sh tests/fm-secondmate-liveness.test.sh tests/fm-context-reset.test.sh tests/fm-crew-state.test.sh tests/fm-tangle-guard.test.sh tests/fm-bootstrap.test.sh tests/fm-send-strict.test.sh- the suites whose stubs needed more than the mechanicallist-paneslinebin/fm-test-run.sh tests/fm-fleet-snapshot-view.test.sh tests/fm-composer-ghost.test.sh tests/fm-tmux-submit-busy.test.sh tests/fm-gate-refuse.test.sh tests/fm-session-start.test.sh tests/fm-secondmate-sync.test.sh- a representative slice of the mechanically-stubbed suitesManual end-to-end:bash e2e-liveness-sweep.sh <fixed-checkout> <base-checkout>- drives the realbin/fm-bootstrap.shsecondmate-liveness sweep against a real tmux 3.4 server on a private socket, for base vs fixed code, across a claude-running and a bare-shell current window, plus live-endpoint positive controlsManual: ran the committed enforcement scanner (unguarded_display_message_readslifted verbatim fromtests/fm-tmux-target-resolve.test.sh) against the base commit'sbin/and the fixedbin/to prove the rule detects the real defect, not only its synthetic probesdocs/tmux-backend.md:232- Judgment call, left unchanged: docs/tmux-backend.md:232's classifier bullet still reads "unknown - anything else, including an unreadable pane" and does not name an unresolvable target. The fact is stated authoritatively 40 lines earlier in the same document ("A target that does not resolve reports unknown, never dead"), so adding it to the bullet would duplicate the owner sentence rather than fix a wrong one. If the bullet list is meant to be the standalone contract, that sentence belongs there and the prose above should shrink to a pointer.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.