Skip to content

fix(bin): prevent idle composer injection wedges - #23

Merged
HelloWorldSungin merged 3 commits into
mainfrom
fm/fm-afk-injection-wedge
Aug 4, 2026
Merged

HelloWorldSungin merged 3 commits into
mainfrom
fm/fm-afk-injection-wedge

Conversation

@HelloWorldSungin

Copy link
Copy Markdown
Owner

Re-landed on the fork from the already-validated branch (previously opened as kunchenguid#1632, which this account cannot merge upstream). Code unchanged.


Intent

Diagnose and fix away mode buffering escalations for about eleven hours without delivering them.

INCIDENT AS REPORTED: On the first real overnight away-mode run (2026-07-28), escalations buffered correctly and every one replayed cleanly on return - nothing was lost - but the daemon could not inject into the primary session for 39,773 seconds, about eleven hours. The wedge alarm fired as designed and surfaced on the return catch-up. Consequence: away mode delivered durability but not responsiveness. Three decisions that should have been handled overnight sat until morning, which is the entire reason for running away mode rather than simply stopping.

CAUSE WAS NOT ESTABLISHED and the report deliberately did not guess. The recorded hypothesis, worth checking FIRST but explicitly NOT to be assumed: the injection guard requires an affirmatively empty composer AND an idle pane, and every verdict other than empty defers, so a persistently non-empty or unreadable primary composer would produce exactly this signature. The primary was a Claude session running a long agentic turn for much of that window. The first question to answer empirically was: does a mid-turn Claude pane ever read as injectable, and if not, is an eleven-hour turn simply outside what the guard was designed for?

REQUIRED APPROACH: (1) Establish the cause from recorded evidence before proposing a fix; if evidence is insufficient, reproduce it. (2) Fix so escalations reach the captain during a long primary turn, OR - if genuinely impossible with the current injection mechanism - say so plainly and propose what would be needed. An honest 'this needs a different delivery channel' was an explicitly valid outcome; a fix that appears to work but still defers silently was explicitly NOT acceptable. (3) Preserve durability: nothing was lost in the recorded incident and nothing may be lost after the change.

ACCEPTANCE CRITERIA: cause established with evidence rather than asserted; a captain-relevant escalation raised while the primary is mid-turn is delivered, or the reason it cannot be is documented with a concrete proposal; buffering and replay-on-return still lose nothing; covered by a test that reproduces the deferring condition; the rule that firstmate never arms a second watcher during away mode is preserved.

CONSTRAINTS: reproduce in a throwaway home, never against the primary checkout's live state/ records, because the live session supervising this work owns those away-mode records. Never pkill watcher or daemon processes, since that can kill sibling firstmate homes. Load and follow the firstmate-coding-guidelines skill before editing firstmate tracked material.

STANDING INSTRUCTION: until fixed, the captain is told plainly that away mode is safe but should not be relied on for overnight responsiveness. If the fix does not fully restore responsiveness, state exactly what it does and does not deliver rather than implying the limitation is gone.

WHAT I FOUND AND DECIDED (deliberate choices a reviewer reading only the diff would not know):

  • The recorded mid-turn hypothesis is DISPROVEN, not adopted. The daemon log shows all three wedges dominated by 'state=pending' deferrals (2,792 on the incident day), while the busy branch a genuine mid-turn pane takes fired only 11 times in the entire log history. The incident is also not a one-off: three wedges of 7.8h (07-23), 11.0h (07-28) and 10.1h (07-31).

  • ACTUAL CAUSE: Claude 2.x renders its EMPTY composer row as the agent glyph followed by U+00A0 NO-BREAK SPACE (bytes e2 9d af c2 a0). Bash's [:space:] trims treat no non-ASCII blank as whitespace under any locale the fleet runs (verified C, C.utf8, en_US.utf8, POSIX), so the pad survived every trim as apparent typed content and the row classified 'pending'. The injector types only into an affirmatively 'empty' composer, so it deferred against a pane that was idle and injectable the whole time. Reproduced live: the primary pane read busy=idle composer=pending before the change and composer=empty after.

  • A SECOND, INDEPENDENT deferring condition was found and fixed in the same path: the leading prompt glyph was removed by character count, and under an exported C/POSIX locale bash's ${content#?} drops one BYTE, leaving the two trailing bytes of a multibyte glyph behind as spurious content. Verified to flip an idle placeholder composer from empty to pending. It is now removed as a literal prefix. This is not scope creep; it is a second instance of the same wedge class on the same code path.

  • The fix lives in the ONE shared composer owner (bin/fm-composer-lib.sh) so every backend adapter (tmux, herdr, orca, cmux) gets it and the adapters cannot drift again, matching that file's existing consolidation rationale.

  • SAFETY DIRECTION IS DELIBERATE AND ONE-WAY: the blank fold only ever folds INVISIBLE characters (non-ASCII blanks and zero-width characters), so any visible byte still reads 'pending' and the existing dead-shell refusal ('unknown' for a bare shell prompt) is untouched. Under-folding merely defers, which the max-defer alarm surfaces; over-folding would inject over real human input, which is the failure this must never cause. Tests cover both directions explicitly.

  • THE INJECTION GUARD ITSELF IS DELIBERATELY UNCHANGED. It was fed a wrong verdict; it was not reasoning wrongly. Changing the guard would have weakened a safety boundary to work around a classifier bug.

  • MID-TURN LIMITATION IS DELIBERATELY RETAINED AND DOCUMENTED, not silently fixed away. A genuinely mid-turn primary still defers on the busy guard, bounded by one turn and self-clearing, with the max-defer wedge alarm still covering a pathological one. Empirically negligible (11 occurrences ever against 5,548 deferrals), so no separate delivery channel is needed. This is recorded in docs/verification/supervision.md as a maintainer-verification fact with exact versions, commands and output, per the coding guidelines' knowledge-placement tree. AGENTS.md is deliberately NOT touched: this is a script-level bug fix with no contract change, and mechanics belong in script headers.

  • EVIDENCE GATHERING RESPECTED THE CONSTRAINT: the live primary pane was only ever READ (read-only ANSI capture and classification). No live state/ record was written, no daemon or watcher was started, stopped or killed, and no second watcher was armed.

  • TWO PRE-EXISTING, UNRELATED TEST FAILURES were found and deliberately NOT fixed here, to keep this change reviewable: tests/fm-calm-pi-extension.test.sh ('/calm left collapsed thinking labels') and tests/fm-test-run.test.sh (exits 1 on a stray 'comm: not in sorted order' while every assertion passes, so the runner reports failed=2 with zero 'not ok' lines). Both reproduce on a clean baseline with these changes stashed. They are reported upward for separate tracking rather than folded in.

  • Regression tests were confirmed to FAIL on the pre-fix code and pass after, so they genuinely reproduce the deferring condition rather than merely asserting current behavior. Lint (bin/fm-lint.sh) and bin/fm-doc-audience-check.sh are clean.

What Changed

  • Normalize invisible Unicode composer padding and strip multibyte prompt glyphs literally, allowing idle composers to remain injectable under C/POSIX locales without weakening typed-input or dead-shell safeguards.
  • Add shared classifier and Herdr regression coverage for Claude’s captured U+00A0-padded composer, multibyte prefixes, visible input, and shell prompts.
  • Document the verified wedge cause, fleet-wide behavior, and the retained mid-turn busy-guard limitation with its existing alert-channel fallback.

Risk Assessment

✅ Low: Captain, the change is well-bounded, fixes the proven classifier failure at the shared owner, preserves fail-closed injection and durability, and now documents the accepted mid-turn limitation with the existing out-of-band alert proposal.

Testing

The base-to-target counterfactual established the cause, focused automated tests validated classification and safety boundaries, and isolated end-to-end checks demonstrated exactly-once Herdr delivery, durable buffering/replay, busy-pane deferral with preservation, wedge alarming, and the one-watcher invariant. No visual artifact was applicable because this is a shell daemon and terminal-integration change; CLI transcripts and persisted-buffer behavior are the end-user evidence.

Evidence: Cause and counterfactual evidence

Recorded Claude empty composer row bytes: e2 9d af c2 a0 0d Baseline verdict: pending Fixed verdict: empty Visible draft remains: pending Bare shell prompt remains: unknown Baseline C-locale placeholder: pending Fixed C-locale placeholder: empty

Recorded Claude empty composer row bytes:  e2 9d af c2 a0 0d
Baseline verdict for ❯ + U+00A0: pending
Fixed verdict for ❯ + U+00A0: empty
Fixed verdict with visible captain draft: pending
Fixed verdict for bare shell prompt + U+00A0: unknown
Baseline C-locale multibyte-placeholder verdict: pending
Fixed C-locale multibyte-placeholder verdict: empty
Evidence: Real Herdr away-mode delivery

All real-Herdr scenarios passed: clean delivery after idle, exactly-once delivery after a swallowed Enter, normal escalation delivery, and buffer preservation with a wedge alarm when delivery remained unsafe.

ok - real herdr Scenario A: partial input defers injection; digest arrives clean after idle
ok - real herdr Scenario B: swallowed Enter (via the herdr shim) produces exactly one clean digest
ok - real herdr Scenario C: a normal captain status injects exactly one clean single-line sentinel digest
ok - real herdr Scenario D: a persistently pending composer raises the max-defer wedge alarm, preserves the buffer, and never crashes the daemon
all real-herdr afk injection e2e tests passed
Evidence: Durable watcher-to-delivery lifecycle

A terminal event survived watcher restart, buffered once without duplication, injected once, and cleared only after successful delivery.

ok - lifecycle: routine self-handles, terminal survives a watcher restart, buffers once, no dup, injects once
ok - lifecycle: stale pane transient self-handles, persistent escalates once and clears, resumed clears quietly
Evidence: One-watcher invariant

Concurrent starts left exactly one watcher; singleton recovery and lifecycle checks completed successfully.

ok - simultaneous watcher starts leave exactly one live process
ok - fm_pid_identity real ps fallback is locale-invariant
ok - fm_pid_identity is locale-invariant across LC_ALL/LC_TIME
ok - /proc process identity ignores simulated btime changes
ok - /proc process identity detects pid reuse
ok - MSYS /proc process identity regression skipped on non-Windows host
ok - killed watcher stale lock is reclaimed
ok - live watcher lock with stale heartbeat is actionable
ok - guard banner leads when down with pending wakes (repair-after-drain) and stays silent when fresh
ok - concurrent fm_lock_try_acquire yields exactly one winner
ok - dead-pid stale lock is reclaimed by a single acquirer
ok - concurrent stale-lock steal yields exactly one winner
ok - live steal mutex is not reclaimed
ok - live-held lock is not stolen
ok - empty mid-acquire lock keeps a minimum grace
ok - late original claimant cannot claim a recreated lock
ok - paused mid-acquire claimant backs off to active stealer
ok - watch restart refuses to signal a reused pid
ok - watch restart attaches to a verified healthy peer and later surfaces a successor gap
ok - watcher self-evicts when the lock pid no longer names it
ok - arm turns clean self-eviction without a successor into a typed failure
ok - arm attaches to a live fresh watcher and fails loudly when that cycle has no successor
ok - attached arm signals record a classified lifecycle entry
ok - arm starts+confirms a fresh watcher on a clean lock and self-heals a dead-pid lock (never healthy off a dead pid)
ok - arm cleans child watcher and temp output on HUP
ok - arm propagates an immediate watcher wake before confirmation
ok - arm attaches to a peer watcher after child stands down and surfaces a missing successor
watcher: lock held by live pid 116225 but heartbeat is stale for 839124253s (>300s); inspect or stop that watcher before re-arming.
ok - arm reports FAILED and exits non-zero when no fresh watcher can be confirmed
ok - cycle-exit ledger links a verified successor and remains size-capped
ok - SIGSTOP distinguishes live PID from stale beacon and termination records the exit class
Evidence: Daemon guard and buffer-preservation checks
ok - fm-afk-start.sh fails before daemon startup when the afk flag cannot be written
ok - fm-afk-start.sh ignores stale pidfile-only live pids
ok - fm-afk-start.sh reclaims stale daemon locks whose live pid identity no longer matches
ok - supervise daemon state root is scoped by FM_HOME
ok - routine signal self-handles
ok - captain-relevant status verbs escalate
ok - check + unknown escalate; heartbeat self-handles
ok - transient stale self-handles and records a persistence marker
ok - enriched stale wedges bypass status absorption without disturbing busy workers
ok - stale + terminal status escalates immediately
ok - paused reasons with captain phrases remain pause-classified
ok - handle_wake on a paused stale records a pause marker, drops the wedge marker, and does not escalate
ok - handle_wake records a declared pause from a routine signal for long-cadence rechecks
ok - a terminal signal clears pause and stale tracking across both supervisors
ok - housekeeping migrates a normal-watcher's declared pause into daemon tracking
ok - housekeeping clears an already-resumed watcher pause across both supervisors
ok - housekeeping seeds pause tracking from status without a watcher marker
ok - persistent stale escalates after threshold and clears its marker
ok - resumed (busy) stale clears its marker without escalating
ok - housekeeping re-surfaces a stale declared pause on the long cadence and resets its window
ok - housekeeping clears a paused marker whose pane became busy again, without escalating
ok - housekeeping clears a paused marker once the crew is no longer declaring the pause
ok - housekeeping moves an existing stale marker to pause before wedge escalation
ok - housekeeping clears tracking when a crew leaves pause
ok - persistent herdr stale resolves the target from metadata and escalates
ok - herdr idle busy-footer stale clears through capture corroboration
ok - resumed herdr stale clears through backend-aware busy state
ok - persistent Orca stale resolves the terminal from metadata
ok - multiple escalations flush as a single batched digest
ok - batch flush measures max-delay from the first append, not the last
ok - catch-all scan escalates a missed terminal once, not twice
ok - handle_wake routes routine->self and captain->escalate
ok - INJECT_SKIP forces self-handle, bypassing captain-relevant classification
ok - is_wake_reason distinguishes watcher wake reasons from singleton-status stdout
ok - terminal-stale escalate removes its marker so housekeeping does not re-escalate
ok - captain signal escalate marks seen so the catch-all scan does not re-fire
ok - _collapse_newlines replaces newlines with literal separator
ok - afk flag absent: daemon does not inject, buffer preserved
ok - busy-guard defers injection when supervisor pane is busy
ok - marker detection: marker -> stay afk, no marker -> exit afk
ok - /afk invocation is exempt from afk exit (no self-cancel)
ok - should_exit_afk returns false when afk is not active
ok - strip_injection_marker removes the sentinel marker cleanly
ok - pane_input_pending detects partial input on the cursor line
ok - pane_input_pending: blank cursor line is not pending
ok - pane_input_pending: only proven empty agent prompts pass
ok - fm_tmux_composer_state: a bare shell prompt ($/%/#/>) reads unknown, never empty (dead-shell injection safety)
ok - fm_tmux_composer_state: a bordered composer box and bare agent glyphs (❯/›) still read empty
ok - fm_tmux_composer_state: only matching edge borders form a composer box
ok - pane_input_pending honors FM_COMPOSER_IDLE_RE after border stripping
ok - classify_signal dedupes against the catch-all scan seen marker
ok - classify_stale dedupes against the signal path seen marker
ok - AFK nonterminal working:+merged keeps wedge aging and re-escalates at bound
ok - genuine done: and merge-check events still escalate
ok - pane_input_pending: an idle bordered composer is NOT pending (afk-invx-i5)
ok - pane_input_pending: text inside a bordered composer is still pending
ok - submit-ACK confirms a submit when the composer returns to a bordered-empty box
ok - submit-ACK reports pending on a persistently swallowed Enter (type-once)
ok - max-defer on an empty stuck pane types once, alarms, and preserves the buffer
ok - max-defer flushes and clears the buffer on an empty bordered pane
ok - max-defer on a pending composer alarms without typing
ok - normal flush clears a stale wedge marker
ok - below MAX_DEFER: no inject, no alarm, buffer preserved
ok - max-defer does not flush or alarm while afk is inactive
ok - library mode: sourcing the daemon defaults FM_WEDGE_ALARM_EXEC to discard (no test can fire a real notification)
ok - wake helpers replace inherited notifier overrides with the safe recorder
ok - the discard seam suppresses every notifier, including command: (fires nothing)
ok - direct notifier helpers honor the discard seam, including command:
ok - osascript channel routes through the notifier seam with the summary (never a real notification)
ok - herdr channel routes through the notifier seam with the summary (never a real notification)
ok - command channel runs the captain command with the summary on $1 and on stdin
ok - command channel failures redact configured commands while logging their exit status
ok - unknown channel directives are redacted while the alarm keeps running
ok - off disables every active alert regardless of directive position (marker and tmux flash are unaffected)
ok - auto resolves to the macOS osascript notifier on Darwin (default-on)
ok - auto on a non-macOS platform selects no built-in OS channel (the marker or a configured command carries it)
ok - config/wedge-alarm selects every configured channel and skips comment and blank lines
ok - a failing channel logs and falls back to the next channel, never crashing the alarm
ok - a hung notifier is bounded, logged, and falls through to the next channel
ok - a backgrounded command notifier remains bounded until its process group is reaped
ok - a hung notifier override is bounded, logged, and proceeds to the next channel
[1]+  Terminated              sh -c 'sleep 30 & printf "%s" "$!" > "$1"; wait' sh "$child_file"
ok - daemon shutdown stops and reaps the active notifier process group
ok - inject_wedge_alarm writes the marker AND emits the active alert even with no tmux status-line (herdr backend)
ok - in-process wedge throttle prevents alert spam when the marker cannot persist
ok - fm-send exits non-zero on a confirmed swallow, zero on a clean submit
ok - fm-send exits non-zero when initial text send fails
ok - fm-send exits non-zero unless delivery is proven empty
ok - discover_supervisor_backend: override > TMUX_PANE > HERDR_ENV+HERDR_PANE_ID > tmux fallback
ok - discover_supervisor_target: override > TMUX_PANE > herdr '<session>:<pane-id>' composition > firstmate:0 fallback
ok - pane_is_busy: herdr native busy_state='busy' short-circuits without a capture fallback
ok - primary busy guard isolates rendered signatures by detected harness
ok - pane_is_busy: omitted backend defaults to tmux for Grok's isolated fallback
ok - pane_input_pending: dispatches through fm_backend_composer_state for backend=herdr
ok - inject_msg: herdr busy-guard defers before ever attempting a submit
ok - inject_msg: herdr composer-guard defers before ever attempting a submit
ok - inject_msg: herdr pane-gone check defers before any busy/composer/submit call
ok - inject_msg: dispatches busy-guard/composer-guard/submit through the herdr backend and succeeds on a confirmed empty composer
ok - inject_msg: defers on a dead-shell/unreadable composer (unknown), never typing the escalation into a shell
ok - inject_msg: unrecognized composer states defer by default
Evidence: Composer regression checks
ok - fm_composer_classify_content: a composer padded with a non-ASCII blank reads empty, not pending (the away-mode injection wedge)
ok - fm_composer_classify_content: visible text surrounded by non-ASCII blanks still reads pending
ok - fm_composer_classify_content: the non-ASCII blank fold does not weaken the dead-shell refusal
ok - fm_composer_classify_content: a multibyte agent glyph is stripped whole, locale-independently
ok - fm_composer_classify_content: a bare shell prompt glyph (>/$/%/#) reads unknown, never empty
ok - fm_composer_classify_content: stripped unbordered content is unknown except verified agent glyphs
ok - fm_composer_classify_content: a bare shell prompt carrying a command is not empty
ok - fm_composer_classify_content: a bare prompt glyph inside a bordered composer box reads empty (claude's own idle composer)
ok - fm_composer_classify_content: agent prompt glyphs (❯ claude, › codex) read empty bordered or bare
ok - fm_composer_classify_content: an empty composer reads empty
ok - fm_composer_classify_content: a known idle placeholder reads empty, before and after glyph stripping
ok - fm_composer_classify_content: idle matching preserves the caller's case mode
ok - fm_composer_classify_content: real unsubmitted text reads pending (including a popup argument-hint fill)
Evidence: Herdr adapter checks
ok - fm_backend_herdr_version_check: accepts the current protocol (14)
ok - fm_backend_herdr_version_check: refuses an old protocol loudly
ok - fm_backend_herdr_version_check: refuses loudly when herdr is not installed
ok - fm_backend_herdr_workspace_label: a primary home (no marker) resolves to 'firstmate'
ok - fm_backend_herdr_workspace_label: a secondmate home (.fm-secondmate-home) resolves to '2ndmate-<id>'
ok - fm_backend_herdr_workspace_label: trims whitespace around the marker's secondmate id
ok - fm_backend_herdr_workspace_label: an empty marker file falls back to the primary label 'firstmate'
ok - fm_backend_herdr_workspace_label: two different secondmate homes get two different, non-colliding labels
ok - fm_backend_herdr_cli: sets HERDR_SESSION AND appends a trailing --session flag on every call
ok - fm_backend_herdr_launcher_identity: a firstmate not running inside herdr has no launcher workspace to inherit
ok - fm_backend_herdr_launcher_identity: HERDR_ENV=1 without a pane id selects the backend but binds no parent
ok - fm_backend_herdr_launcher_identity: resolves the launcher's exact workspace even when a same-labeled workspace sorts first
ok - fm_backend_herdr_launcher_identity: refuses a launcher pane that names a different herdr session
ok - fm_backend_herdr_launcher_identity: refuses a claimed pane without exact server identity
ok - fm_backend_herdr_launcher_identity: refuses a launcher pane whose injected socket belongs to another herdr server
ok - fm_backend_herdr_launcher_identity: refuses when the launcher's own pane no longer resolves
ok - fm_backend_herdr_launcher_identity: refuses when the launcher's pane and tab disagree about their workspace
ok - fm_backend_herdr_launcher_identity: refuses when the launcher's workspace is gone from its own session
ok - fm_backend_herdr_workspace_ensure: places a worker in the launcher's exact workspace, not the first same-labeled one
ok - fm_backend_herdr_workspace_ensure: refuses to guess between two same-labeled home workspaces
ok - fm_backend_herdr_workspace_ensure: a --secondmate container resolves that home's own workspace, not the launcher's
ok - fm_backend_herdr_container_ensure: surfaces the exact ambiguous-placement refusal instead of a generic failure
ok - fm_backend_herdr_container_ensure: version-gates, starts the server, ensures the firstmate workspace, echoes session:workspace_id + the seeded default tab id
ok - fm_backend_herdr_container_ensure: reuses an existing firstmate workspace without recreating it, and reports no seeded default tab (adopted, not created)
ok - fm_backend_herdr_container_ensure: workspace create passes --no-focus
ok - fm_backend_herdr_container_ensure: creates the workspace under the SECONDMATE home's own label, not 'firstmate'
ok - fm_backend_herdr_create_task: prunes exactly the seeded default tab container_ensure identified, once the first real task tab exists
ok - herdr repeated spawn/teardown: one persistent firstmate workspace reused, zero orphans, default tab pruned, create ran once
ok - fm_backend_herdr_create_task: an ADOPTED workspace's pre-existing tab is never pruned (the created-vs-adopted gate)
ok - fm_backend_herdr_create_task: the label-collision startup-workspace scenario (2026-07-02 incident) leaves the captain's live tab untouched
ok - fm_backend_herdr_workspace_prune_seeded_default_tab: refuses to close the seeded default tab when its pane reports a working agent (defense in depth)
ok - fm_backend_herdr_create_task: refuses a duplicate tab label (herdr's own tab create has no uniqueness check)
ok - fm_backend_herdr_create_task: a same-labeled tab with a live (even idle) registered agent still refuses exactly as before
ok - fm_backend_herdr_create_task: scans every same-labeled tab and refuses if any duplicate is live
ok - fm_backend_herdr_create_task: closes and replaces a same-labeled tab whose pane is dead (pane_not_found)
ok - fm_backend_herdr_create_task: closes and replaces a same-labeled tab whose pane is alive but hosts no registered agent (a restored plain shell)
ok - fm_backend_herdr_create_task: closes every confirmed same-labeled husk only after creating the replacement
ok - fm_backend_herdr_create_task: refuses success when a preexisting husk tab remains after replacement
ok - fm_backend_herdr_create_task: refuses (fail-safe) rather than guessing when the duplicate's agent state cannot be classified confidently
ok - fm_backend_herdr_create_task: creates the replacement tab BEFORE closing the husk tab, never the reverse
ok - fm_backend_herdr_create_task: creates a tab and parses tab_id/pane_id from the JSON response, prunes nothing when no seeded tab id is given
ok - fm_backend_herdr_create_task: tab create passes --no-focus
ok - herdr presentation journal: atomically publishes one non-authoritative 128-bit correlator and refuses overwrite
ok - herdr presentation journal: version 2 binds exact home/endpoint/parent identities and advances atomically
ok - herdr presentation create: exact response IDs yield one normal task pane with no workspace-close authority
ok - herdr presentation create: concurrent same-label tabs are never prune targets
ok - herdr presentation focus: snapshot requires one exact active workspace and tab
ok - herdr presentation focus: exact pane close restores the exact prior workspace and tab
ok - herdr presentation focus: cleanup refuses rather than close the captain's active tab
ok - herdr presentation focus: pane close fails when exact focus restoration fails
ok - herdr presentation reclaim: live agent state at the close boundary refuses mutation
ok - herdr presentation cleanup: emptying close behind focus ends the exact shell without a move or focus change
ok - herdr presentation cleanup: emptying close before focus moves the doomed workspace to the end and ends its exact shell
ok - herdr presentation cleanup: emptying close with the focused workspace last skips the move
ok - herdr presentation cleanup: emptying close of the last workspace skips the move
ok - herdr presentation cleanup: a non-emptying close stays plain with no proof, move, or signal
ok - herdr presentation cleanup: no-move plain close requires structured pane removal
ok - herdr presentation cleanup: an ambiguous workspace layout falls back to the plain close
ok - herdr presentation cleanup: a failed repositioning move falls back to the plain close with a warning
ok - herdr presentation cleanup: a pane with a live foreground process falls back to the plain close
ok - herdr presentation cleanup: a transient prompt helper settles into the pane-death path instead of the plain close
tests/fm-backend-herdr.test.sh: line 1493: 67379 Killed                  bash -c 'trap "" HUP; sleep 300'
ok - herdr presentation cleanup: a SIGHUP-surviving shell is escalated to SIGKILL before giving up
tests/fm-backend-herdr.test.sh: line 1531: 67572 Killed                  bash -c 'trap "" HUP; sleep 300'
ok - herdr presentation cleanup: a failed pane-death close falls back to the plain close
tests/fm-backend-herdr.test.sh: line 1564: 67797 Hangup                  sleep 300
ok - herdr presentation cleanup: the exact-tab restore remains the backstop behind the pane-death close
ok - herdr presentation cleanup: SIGKILL never reaches a pid the exact pane no longer owns
ok - herdr presentation cleanup: every unconfirmed removal restores the exact original workspace order and reports failure
tests/fm-backend-herdr.test.sh: line 1723: 68963 Hangup                  sleep 300
ok - fm_backend_herdr_kill: one session lock covers the focus-safe emptying removal
ok - fm_backend_herdr_kill: killing the focused workspace's tab keeps the legitimate plain close
ok - endpoint confirmed-gone: only structured not-found permits record removal and ambiguous identity refuses
ok - fm_backend_herdr_kill: unavailable session locks defer every pane close
ok - herdr presentation focus: projected seeded pruning refuses the active tab
ok - herdr presentation labels: └ concise-task · p:<full-token> for primary and secondmate children
ok - herdr presentation ordering: exact new workspace appends to the primary block while focus and relative orders stay stable
ok - herdr p

... [1552 bytes truncated] ...

 reclaim may proceed, otherwise spawning flat
ok - herdr presentation recovery: duplicate-token inspection is read-only and live-agent risk refuses fallback
ok - fm_backend_herdr_workspace_find: matches only THIS home's own label among several coexisting workspaces
ok - fm_backend_herdr_list_live: scoped to this home's own workspace, never a sibling home's
ok - fm_backend_herdr_parse_target: splits '<session>:<pane_id>' on the FIRST colon (pane_id itself contains one)
ok - fm_backend_herdr_normalize_key: Enter/Escape/C-c map to herdr's verified enter/escape/ctrl+c
ok - fm_backend_herdr_capture: calls 'pane read <pane> --source recent --lines N' with the session set
ok - fm_backend_herdr_capture: works around the verified small-N '--lines' bug by over-fetching and trimming locally
ok - fm_backend_herdr_capture: ensures the session and preserves pane read failure
ok - fm_backend_herdr_send_key: normalizes the key and targets the right pane
ok - fm_backend_herdr_kill: calls pane close and stays best-effort on failure
ok - fm_backend_herdr_current_path: reads pane foreground_cwd (the live running process), not the frozen creation-time cwd
ok - fm_backend_herdr_busy_state: working -> busy
ok - fm_backend_herdr_busy_state: done -> idle, blocked -> idle (surfaced like a stale pane, not suppressed as busy)
ok - fm_backend_herdr_busy_state: unparseable/absent agent state reports unknown, the regex-fallback cue
ok - fm_backend_herdr_composer_state: a bare '❯' composer row reads empty
ok - fm_backend_herdr_composer_state: the ghost placeholder text reads empty, not pending
ok - fm_backend_herdr_composer_state: real composer text reads pending
ok - fm_backend_herdr_composer_state: claude's U+00A0-padded empty composer reads empty, not pending (the wedge fix)
ok - fm_backend_herdr_composer_state: real text after a U+00A0 pad still reads pending (no over-strip)
ok - fm_backend_herdr_composer_state: a slash-command popup's argument-hint placeholder still reads pending (the incident fix)
ok - fm_backend_herdr_composer_state: reports unknown when the pane cannot be captured
ok - fm_backend_herdr_composer_state: reports unknown for bare shell prompts with no composer row
ok - fm_backend_herdr_composer_state: a native idle Pi separator composer reads empty
ok - fm_backend_herdr_composer_state: real Pi composer text remains pending
ok - fm_backend_herdr_composer_state: an incomplete lower Pi separator cannot inherit a stale empty row
ok - fm_backend_herdr_composer_state: Pi separators never authorize working, non-Pi, unreadable, or over-tall targets
ok - fm_backend_herdr_composer_state: a real-claude unbordered '❯' prompt row (no border box in view) reads empty
ok - fm_backend_herdr_composer_state: a real-claude unbordered '❯ <text>' prompt row reads pending
ok - fm_backend_herdr_composer_state: a live unbordered prompt row below a stale bordered decorative box still wins (not misread as the box's own row)
ok - fm_backend_herdr_composer_state: claude's dim prompt-suggestion ghost (the overnight wedge shape) reads empty
ok - fm_backend_herdr_composer_state: real typed text on the same claude prompt row still reads pending
ok - fm_backend_herdr_composer_state: grok's dark-truecolor placeholder (the TRUECOLOR gap) reads empty
ok - fm_backend_herdr_composer_state: grok's real bright typed input still reads pending
ok - fm_backend_herdr_composer_state: a real-codex unbordered '›' prompt row reads empty
ok - fm_backend_herdr_composer_state: a faint real-codex ghost suggestion reads empty
ok - fm_backend_herdr_composer_state: non-faint codex prompt text still reads pending
ok - fm_backend_herdr_wait_for_working: reports 'busy' immediately on the first poll, without spending the rest of the budget
ok - fm_backend_herdr_wait_for_working: a slow transition landing on a later sample within one window is still caught (robust against the 'slow transition' failure direction)
ok - fm_backend_herdr_wait_for_working: spreads six samples across the full budget endpoint without a final trailing sleep
ok - fm_backend_herdr_send_text_submit: applies the herdr minimum confirmation budget before polling agent-state
ok - fm_backend_herdr_wait_for_working: reports 'idle' (readable, genuinely not yet working) when 'busy' never appears
ok - fm_backend_herdr_wait_for_working: reports 'unknown' (a hard read failure, not a timing race) only when EVERY poll in the window fails
ok - fm_backend_herdr_wait_for_working: treats blocked as submit-active for confirmation without changing watcher busy-state semantics
ok - fm_backend_herdr_send_text_submit: reports 'empty' once agent_status reports working after one Enter, without ever reading the composer
ok - fm_backend_herdr_send_text_submit: reports 'pending' when agent_status never reports working after retried Enters (swallowed)
ok - fm_backend_herdr_send_text_submit: a slash-command popup's placeholder fill on Enter #1 never flips agent_status to working, so it does not short-circuit as submitted; Enter #2 is retried and lands it
ok - fm_backend_herdr_send_text_submit: a post-Enter blocked state confirms delivery without retrying into the prompt
ok - fm_backend_herdr_send_text_submit: preexisting working is not accepted as submit proof when the composer still holds the message
ok - fm_backend_herdr_send_text_submit: confirms submission via native agent-state alone, immune to a codex-style dynamic idle-tip composer that would have misread as 'pending' under the old composer-based confirmation
ok - fm_backend_herdr_composer_state: a faint real-codex dynamic idle-tip composer row reads empty
ok - fm_backend_composer_state (herdr): the pre-injection empty-box guard still refuses a genuinely non-empty composer, unaffected by the submit-confirmation change
ok - fm_backend_herdr_send_text_submit: a slow transition landing on a later sample within one Enter's budget is confirmed WITHOUT sending a needless extra Enter
ok - fm_backend_herdr_send_text_submit: reports 'send-failed' when the literal send-text call itself errors
ok - fm_backend_herdr_send_text_submit: reports 'unknown' when the post-Enter agent-get read fails (never retries past an unreadable target)
ok - fm_backend_validate: herdr is a known backend (P2)
ok - fm_backend_busy_state: tmux (no native primitive) always reports unknown, preserving the P1 regex-only path
error: unknown backend 'bogus' (known: tmux herdr zellij orca cmux)
ok - fm_backend_composer_state dispatches tmux/herdr/orca to their named classifiers, unknown for zellij/unrecognized backends
ok - fm-peek/fm-send: explicit stale targets matching metadata use the recorded backend
ok - fm_backend_herdr_normalize_event routes through the shared record with an empty from_status
ok - fm_backend_herdr_escalation_marker keys the dedupe marker exactly like the watcher's .stale-<key>
ok - fm_backend_herdr_apply_transition: blocked dedupe starts only after explicit commit
ok - fm_backend_herdr_apply_transition: a working edge clears the marker so the next ->blocked re-escalates
ok - fm_backend_herdr_clear_transition removes task-owned dedupe state
ok - fm_backend_herdr_apply_transition: idle/done (defer) and unknown/empty (fallback) take no fast action
ok - fm_backend_herdr_wait_transition: a home with no herdr panes falls back to polling (rc 2)
ok - fm_backend_herdr_wait_transition: below-capability protocol/schema falls back to polling (rc 2)
ok - fm_backend_herdr_wait_transition: reconnect level-reconcile returns an uncommitted blocked pane
ok - fm_backend_herdr_wait_transition: subscribes before reconnect level-reconcile
ok - fm_backend_herdr_wait_transition: a still-blocked, already-escalated pane is not re-delivered on reconnect
ok - fm_backend_herdr_wait_transition: a streamed ->blocked edge returns the record sub-poll
ok - fm_backend_herdr_wait_transition: streamed working clears the marker, idle/done are deferred (clean timeout)
ok - fm_backend_herdr_wait_transition: a reader/subscribe failure falls back to polling (rc 2)
ok - fm_backend_herdr_wait_transition: Bash 3.2-safe bad-ack path closes fd 9 and removes its FIFO
ok - fm_backend_herdr_wait_transition: stock macOS Bash clean timeout closes fd 9 and returns 1

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • 🚨 docs/verification/supervision.md:285 - The required approach says escalations must "reach the captain during a long primary turn" or the change must document why that is impossible and provide a concrete proposal. This hunk instead says delivery waits until the turn ends. The reachable sequence remains: escalate_flush calls inject_msg, the busy guard returns failure, the durable buffer is retained, and every housekeeping retry follows the same path for the entire potentially unbounded turn. Captain approval is needed either to waive this criterion or to add a concrete out-of-band proposal, such as sending the digest through the existing backend-independent alert channel while retaining it for replay.

🔧 Fix: Document mid-turn escalation delivery limitation
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • tests/fm-composer-lib.test.sh
  • tests/fm-backend-herdr.test.sh
  • tests/fm-daemon.test.sh
  • tests/fm-wake-daemon-lifecycle-e2e.test.sh
  • tests/fm-watcher-lock.test.sh
  • tests/fm-afk-inject-herdr-e2e.test.sh against Herdr 0.7.5 in its isolated named lab
  • Compared base 1e247571aa75e00b00c7a01c4830025ecd44dc61 and target classifiers using the captured e2 9d af c2 a0 row and the exported LC_ALL=C multibyte-prompt case
  • Verified git status --short remained clean and no test watcher or Herdr lab process remained
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Sungin Kim added 3 commits August 1, 2026 23:18
The away-mode sub-supervisor buffered escalations correctly and replayed
every one on return, but could not inject into the primary session for up
to eleven hours at a time - three overnight wedges of 7.8h, 11.0h, and
10.1h. Durability held; responsiveness did not.

The recorded hypothesis was that a long mid-turn Claude pane is simply
outside what the injection guard was designed for. The daemon log
disproves it: all three wedges are dominated by `state=pending`
deferrals (2792 on the incident day alone), while the busy branch that a
genuine mid-turn pane takes fired 11 times in the entire log history.

Root cause is a misread of an idle, injectable composer. Claude 2.x
renders its EMPTY composer row as `❯` + U+00A0 NO-BREAK SPACE, and bash's
`[:space:]` trims treat no non-ASCII blank as whitespace under any locale
the fleet runs, so the pad survived every trim as apparent typed content
and classified `pending`. The injector types only into an affirmatively
`empty` composer, so it deferred forever against a pane that was ready
the whole time. Reproduced live on the primary pane, which read
busy=idle composer=pending before this change and composer=empty after.

fm_composer_blank_normalize folds non-ASCII blank and zero-width
characters to an ASCII space before the verdict, in the shared owner so
every adapter gets it. The fold is safe in one direction only: every
character it folds is invisible, so any visible byte still reads
`pending` and the dead-shell refusal is untouched.

The leading prompt glyph is now removed as a literal prefix. Under an
exported C/POSIX locale `${content#?}` drops one BYTE, leaving the two
trailing bytes of a multibyte glyph behind as spurious content - a second,
independent deferring condition, verified to turn an idle placeholder
composer from `empty` into `pending`.

The injection guard itself is unchanged: it was fed a wrong verdict, not
reasoning wrongly. A genuinely mid-turn primary still defers on the busy
guard, bounded by one turn and self-clearing, with the max-defer wedge
alarm still covering a pathological one.

Buffering and replay-on-return are untouched, and nothing is delivered
that was not delivered before.
@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@HelloWorldSungin
HelloWorldSungin merged commit e24c1b7 into main Aug 4, 2026
13 checks passed
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