fix(composer): treat U+00A0 as whitespace so an idle Claude composer reads empty #4

Merged
john merged 1 commit from fm/composer-nbsp-fix into main 2026-08-07 20:02:03 +00:00
Owner

Fixes the away-mode wedge that cost two full nights: 5h50m on 2026-08-05 and 7h32m on 2026-08-07.

The symptom

The captain goes to bed with work running. Workers finish within about fifteen minutes. The captain is never told and firstmate is never woken, so a fleet that could have kept working idles until morning. On return, bin/fm-afk-return.sh reports away-mode inject WEDGED: 27181s undelivered.

state/.supervise-daemon.log carries 3,362 occurrences, one every ~15 seconds continuously since the 5th, of exactly one line:

inject deferred: supervisor composer not confirmed-empty
  (state=pending: pending input, dead-shell prompt, or unreadable pane)

The cause: one invisible character

Claude renders its idle composer row as the prompt glyph (U+276F) followed immediately by U+00A0 NO-BREAK SPACE — not an ASCII space.

fm_composer_classify_content trims trailing whitespace with the bash idiom ${var%"${var##*[![:space:]]}"}, and bash's [:space:] does not include U+00A0 (verified under both LC_ALL=C and en_US.UTF-8). So once the glyph is stripped, the NBSP survives as "content", is non-empty, matches no idle placeholder, and the verdict is pending.

The daemon's guard then does exactly what it should: refuses to inject into a composer it believes holds unsent text. Forever.

The guard's logic was never wrong. Its idea of what a real idle composer looks like was.

Why the existing tests did not catch it

tests/fm-afk-inject-e2e.test.sh passes both its scenarios — partial input defers injection and swallowed Enter produces exactly one clean digest. Its fixtures use an ASCII space. The injection path was covered, the coverage was green, and the defect lived in the gap between the fixture and the real terminal.

The fix

Normalize U+00A0 to a regular space at the top of fm_composer_classify_content — one shared leaf, so every backend and caller benefits. Eight lines, with a comment recording the reasoning so it is not "simplified away" later.

What it lets through, stated explicitly

Verified against the real classifier:

input verdict
glyph + NBSP (the real idle composer) empty
glyph + NBSP + typed text pending
glyph + space + typed text pending
glyph alone empty

The anti-corruption guarantee is not weakened. A NBSP between the glyph and real input collapses to a space, and the typed text still reads pending, so the daemon still refuses to inject over anything the captain has started typing.

Cleared of suspicion

  • Local a73484c (locale-independent glyph strip) is not implicated — the defect reproduces before and after it.
  • The upstream Muse glyph is not implicated. This branch is rebased onto the merged upstream and both changes coexist: the NBSP normalization and all six occurrences are present.

Residual, deliberately not fixed here

The fix targets U+00A0 because that is the observed byte. Other Unicode whitespace bash also excludes — U+2009 thin space, U+2003 em space — would behave identically if a harness ever emitted one. None is observed in live captures. A general "trim all Unicode whitespace" helper would be more robust but is a much larger change; this is the smallest fix that resolves the observed defect.

The second half, already done

The wedge had a masking condition: when the daemon gave up and raised its max-defer alarm, the alarm had nowhere to go. config/wedge-alarm was absent, absent means auto, and auto resolves to a macOS-only channel — this fleet is Linux. The only output was a marker file nothing reads. A working herdr channel is now configured and verified firing. That is local config, not part of this change.

Tests

fm-composer-lib 10/10, fm-composer-ghost 31/31, fm-backend-herdr 189/189 — all green on the rebased branch, plus two new regression tests pinning the glyph+NBSP shape.

Fixes the away-mode wedge that cost two full nights: 5h50m on 2026-08-05 and 7h32m on 2026-08-07. ## The symptom The captain goes to bed with work running. Workers finish within about fifteen minutes. **The captain is never told and firstmate is never woken**, so a fleet that could have kept working idles until morning. On return, `bin/fm-afk-return.sh` reports `away-mode inject WEDGED: 27181s undelivered`. `state/.supervise-daemon.log` carries **3,362** occurrences, one every ~15 seconds continuously since the 5th, of exactly one line: ``` inject deferred: supervisor composer not confirmed-empty (state=pending: pending input, dead-shell prompt, or unreadable pane) ``` ## The cause: one invisible character Claude renders its **idle** composer row as the prompt glyph `❯` (U+276F) followed immediately by **U+00A0 NO-BREAK SPACE** — not an ASCII space. `fm_composer_classify_content` trims trailing whitespace with the bash idiom `${var%"${var##*[![:space:]]}"}`, and **bash's `[:space:]` does not include U+00A0** (verified under both `LC_ALL=C` and `en_US.UTF-8`). So once the glyph is stripped, the NBSP survives as "content", is non-empty, matches no idle placeholder, and the verdict is `pending`. The daemon's guard then does exactly what it should: refuses to inject into a composer it believes holds unsent text. Forever. **The guard's logic was never wrong. Its idea of what a real idle composer looks like was.** ## Why the existing tests did not catch it `tests/fm-afk-inject-e2e.test.sh` passes both its scenarios — *partial input defers injection* and *swallowed Enter produces exactly one clean digest*. Its fixtures use an ASCII space. The injection path was covered, the coverage was green, and the defect lived in the gap between the fixture and the real terminal. ## The fix Normalize U+00A0 to a regular space at the top of `fm_composer_classify_content` — one shared leaf, so every backend and caller benefits. Eight lines, with a comment recording the reasoning so it is not "simplified away" later. ## What it lets through, stated explicitly Verified against the real classifier: | input | verdict | |---|---| | glyph + NBSP (the real idle composer) | `empty` | | glyph + NBSP + typed text | `pending` | | glyph + space + typed text | `pending` | | glyph alone | `empty` | **The anti-corruption guarantee is not weakened.** A NBSP between the glyph and real input collapses to a space, and the typed text still reads `pending`, so the daemon still refuses to inject over anything the captain has started typing. ## Cleared of suspicion - Local `a73484c` (locale-independent glyph strip) is **not** implicated — the defect reproduces before and after it. - The upstream Muse `⟩` glyph is **not** implicated. This branch is rebased onto the merged upstream and both changes coexist: the NBSP normalization and all six `⟩` occurrences are present. ## Residual, deliberately not fixed here The fix targets U+00A0 because that is the observed byte. Other Unicode whitespace bash also excludes — U+2009 thin space, U+2003 em space — would behave identically if a harness ever emitted one. None is observed in live captures. A general "trim all Unicode whitespace" helper would be more robust but is a much larger change; this is the smallest fix that resolves the observed defect. ## The second half, already done The wedge had a masking condition: when the daemon gave up and raised its max-defer alarm, **the alarm had nowhere to go**. `config/wedge-alarm` was absent, absent means `auto`, and `auto` resolves to a macOS-only channel — this fleet is Linux. The only output was a marker file nothing reads. A working `herdr` channel is now configured and verified firing. That is local config, not part of this change. ## Tests `fm-composer-lib` 10/10, `fm-composer-ghost` 31/31, `fm-backend-herdr` 189/189 — all green on the rebased branch, plus two new regression tests pinning the glyph+NBSP shape.
fix(composer): treat U+00A0 NBSP as whitespace so idle Claude reads empty
Some checks failed
CI / Behavior portable serial 2 (pull_request) Has been cancelled
CI / Behavior portable serial 3 (pull_request) Has been cancelled
CI / Behavior portable serial 4 (pull_request) Has been cancelled
CI / Behavior tests (Herdr) (pull_request) Has been cancelled
CI / Behavior portable parallel 1 (pull_request) Has been cancelled
CI / Behavior portable parallel 2 (pull_request) Has been cancelled
CI / Behavior portable serial 1 (pull_request) Has been cancelled
CI / Lint shell scripts (pull_request) Has been cancelled
CI / Test coverage guard (pull_request) Has been cancelled
CI / Behavior timing aggregate (pull_request) Has been cancelled
CI / Stock macOS Bash snapshot compatibility (pull_request) Has been cancelled
CI / Repo invariants (pull_request) Has been cancelled
Require no-mistakes / PR must be raised via no-mistakes (pull_request) Has been cancelled
7bcd323c98
Real Claude under herdr 0.7.5 renders its idle composer as the prompt glyph
"❯" followed by U+00A0 (NO-BREAK SPACE), not an ASCII space. The shared
composer classifier trims trailing whitespace with bash's [:space:] class,
which excludes U+00A0, so after the glyph is stripped the NBSP survives as
"content" and a truly-empty idle composer reads "pending". The away-mode
daemon's pre-injection guard then defers every escalation forever — the
overnight wedge that cost 2026-08-05 (5h50m) and 2026-08-07 (7h32m).

Normalize U+00A0 to a regular ASCII space at the top of
fm_composer_classify_content (the one shared leaf reached by every adapter:
tmux, herdr, orca, cmux) so the existing ASCII-whitespace trim handles it.
This does not widen "empty" for any row carrying real typed text: a NBSP
between the glyph and real input collapses to a space and the real text
still reads pending. Verified on two live idle claude panes (both reproduced
the false pending; both read empty after the fix). a73484c and the in-flight
upstream Muse "⟩" merge are not implicated (pending before and after both).

Adds two regression tests pinning the shape:
- tests/fm-composer-lib.test.sh::test_claude_nbsp_idle_is_empty
- tests/fm-backend-herdr.test.sh::test_composer_state_claude_nbsp_idle_is_empty
  (feeds the real captured shape — "❯"+NBSP between two truecolor ─ separator
  rows — through fm_backend_herdr_composer_state and asserts empty)

Full suite green: fm-composer-lib 10/10, fm-composer-ghost 30/30,
fm-backend-herdr 177/177.
john merged commit 72f1abbcee into main 2026-08-07 20:02:03 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
john/firstmate!4
No description provided.