Skip to content

fix(bin): report a live worker whose composer holds unsent doorbell text as unable to receive messages - #39

Merged
marano merged 1 commit into
mainfrom
fm/fm-pane-delivery-doorbell
Sep 19, 2026
Merged

marano merged 1 commit into
mainfrom
fm/fm-pane-delivery-doorbell

Conversation

@marano

@marano marano commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Scope

This PR delivers item 2 of 2 from the intent below: a live worker whose composer holds a steer's doorbell unsent is now reported as unable to receive messages, instead of every re-ring queuing behind it.
Item 1 - a spawn that reports success on a garbled launch, and relaunch clearing a stuck shell prompt - shipped separately in #37.
Recovery stays manual as the intent requires: nothing interrupts or clears a composer automatically.

Intent

"always make sure your lanes are filled with work" (2026-09-19). Standing lane rule (2026-09-17): firstmate's own work earns a lane when "they prevent firstmate from working properly"; "I cant get work done if firstmate is not shipshape".

Two recorded defects, both the same failure: text typed into a worker's terminal pane does not land, and nothing notices, so a worker silently never starts or silently stops.

  1. A spawn reports success on a garbled launch (recorded 2026-09-18):
    2026-09-18: fm-spawn.sh reported spawned blu-orgunit-lock-live-tenant-walkthrough but no agent ever started. The launch command typed into the fresh tmux pane arrived garbled mid-string ("...bin/fm-operational-in" followed directly by "env -u CURSOR_AGENT ..." from the start of the command), leaving zsh at a dquote cmdsubst quote> continuation prompt. A following fm-control.sh relaunch typed its command into that same open quote and correctly reported that no agent came up; clearing the prompt with C-c and relaunching worked.
    Two defects: (1) spawn reports success without confirming an agent is running - same family as fm-spawn-accepts-missing-harness, but here the harness exists and the typed command itself was corrupted; (2) neither spawn nor relaunch clears a shell continuation prompt before typing, so one bad launch poisons every retry in that endpoint.

  2. A steer's doorbell sits queued and unsent (recorded 2026-09-18):
    A steer's doorbell can sit QUEUED and unsent in a live worker's composer, and every re-ring queues
    behind it, so the escalation ladder never reaches the worker. Observed 2026-09-18 on
    blu-3192-app-web-consume-lock-signals.

WHAT WAS SEEN:

  • The instruction itself was delivered correctly - the durable inbox record existed and was intact.
  • The doorbell line was typed into the worker's pane and never submitted. The pane read
    "Press up to edit queued messages" and "ctrl+x ctrl+s to send now".
  • The worker was ALIVE, idle, and had no idea a message was waiting. It simply stopped.
  • bin/fm-control.sh interrupt cleared the composer; a fresh send then landed immediately and the
    worker acknowledged and resumed. Nothing was lost.

WHY THE EXISTING SAFETY NET DID NOT CATCH IT. The durable-inbox design is correct and is why no
work was lost: the doorbell is explicitly NOT treated as delivery proof, and the watcher re-rings
an unacknowledged message and escalates a stuck one. But a re-ring is another doorbell typed into
the SAME composer - which is still stuck - so each retry queues behind the last. The ladder climbs
a wall. The stale alarm did eventually fire, which is how firstmate found it, but the diagnosis
came from reading the pane by eye, not from anything the system reported.

Note the asymmetry worth preserving: for a DEAD agent the watcher already reports the right thing -
"unread firstmate instruction ... the worker's agent has exited or its endpoint is missing, so the
doorbell was not typed". There is no equivalent for a LIVE agent whose composer will not accept the
submit, which is the harder and quieter case.

THE CHANGE, in outline rather than prescription: the composer classifier in bin/fm-composer-lib.sh
already distinguishes empty / pending / pending-unproven / unknown, and the away daemon already
refuses to inject into anything but a confirmed-empty composer. That knowledge is not reaching the
steer path's retry logic. A doorbell whose composer reads pending across successive attempts is a
distinct condition - text stuck, agent alive - and should be reported as that rather than retried
indefinitely, so a supervisor is told "this worker cannot receive messages" instead of inferring it.

PROOF OBLIGATIONS - each needs a mutant that reds BY NAME:

  • A live worker whose composer holds unsent text across N re-rings raises a distinct alarm naming
    that condition, not a generic stale wake.
  • Refusal test that must stay green: an ordinary busy worker mid-turn, whose composer legitimately
    holds queued text that WILL submit, is not alarmed on. This is the hard part - OpenCode keeps
    queued text visible while working, and fm_composer_queued_enter_verdict exists precisely because
    visible text alone does not prove a swallowed Enter. Do not turn a normal busy pane into noise.
  • Recovery is not automated by this card. Report the condition; a human or a later card decides
    whether an automatic interrupt is safe.

RELATED, same root family: fm-supervisor-ghost-injection, merged 2026-09-18, fixed the case where
an UNDELIVERABLE escalation was typed into a composer anyway and surfaced hours later. That fix
covers the away daemon's own escalations. It does NOT cover this path - a steer's doorbell to a
worker - so do not assume it is already handled.

What Changed

  • The composer classifier in bin/fm-composer-lib.sh now reads a bare claude composer as pending when it shows queued input. It checks two signals: the Press up to edit queued messages placeholder, and the ctrl+x ctrl+s to send now hint within a few rows above the composer. Before this, ghost stripping made such a pane read empty.
  • fm_task_inbox_ring returns a new code 4 when the composer still holds the text after the submit on a pane that is not busy. fm_task_inbox_record_ring counts consecutive stuck attempts in a fourth .ring-state field. fm_task_inbox_due_action returns a new stuck verb when every attempt in a spent budget found the composer holding text. fm-send.sh and fm-remote-secondmate-control.sh print a notice for code 4.
  • fm-watch.sh raises a distinct wake, stale: <window> (worker cannot receive messages: ...), for the stuck verb. The wake never clears or interrupts the composer. A busy worker whose queued text later submits is not reported. The shared escalation code is factored into inbox_steer_escalate. The stuck-crewmate-recovery skill and docs/verification/runtime-backends.md document the alarm, and the docs cover the queued-input shape verified live on claude 2.1.278. Unit and live e2e tests were added or extended in tests/fm-composer-lib.test.sh, tests/fm-task-inbox.test.sh and tests/fm-send-inbox-doorbell-live-e2e.test.sh.

Risk Assessment

✅ Low: The change is well bounded: the composer classifier reads queued claude input as pending, the ladder tracks consecutive stuck attempts and raises a distinct stale wake, and busy panes stay skipped before any attempt is counted. I found no concrete failing sequence, but I did not run the tests and read only the source diff.

Testing

I ran the two targeted test scripts, which drive the real watcher and inbox ring against tmux panes with stub composers. Both passed with no failures. The live-harness e2e test needs real model tokens and an opt-in flag, so I did not run it. Neither test reproduces the spawn garbled-launch defect (item 1 of the intent), so that scenario is untested.

  • Live validation: ✅ go - 5 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A live idle worker whose composer never submits is named as unable to receive messages, not a generic stale wake ✅ pass live fm-task-inbox.tap: 'watcher: a live idle worker whose composer never submits is named as unable to receive messages'
A claude worker idle with a stranded queued doorbell is named as unable to receive messages ✅ pass live fm-task-inbox.tap: 'watcher: claude idle with a stranded queued doorbell is named as unable to receive messages'
A busy worker whose queued text will still submit is never rung into or alarmed on (refusal case) ✅ pass live fm-task-inbox.tap: 'watcher: a busy worker whose queued text will still submit is never rung into or alarmed on'
The re-ring ladder names only a composer stuck through the whole budget ✅ pass live fm-task-inbox.tap: 'inbox: the ladder names a composer stuck through the whole budget, and only that'
The composer classifier proves the caller's own text is held in the composer (fm_composer_holds_text) ✅ pass live fm-composer-lib.tap: 'fm_composer_holds_text' cases
Live real-agent doorbell e2e (fm-send-inbox-doorbell-live-e2e.test.sh) ⏸️ untested no Needs FM_SEND_INBOX_LIVE_E2E=1 plus installed harnesses (claude, codex) and spends real model tokens. Opt in to run it.
fm-spawn.sh reports success on a garbled launch, and spawn/relaunch do not clear a shell continuation prompt ⏸️ untested no This change does not touch fm-spawn.sh or fm-control.sh relaunch. Only the spawn-related commit (2f8f630) is in the base. Nothing here to drive.
Evidence: task-inbox test output

Source: task-inbox test output

fm-build-lock: waiting for the machine-wide build lock - this process is WAITING, not wedged
fm-build-lock: still waiting 1m00s for the machine-wide build lock - position 3 of 3 in line - held by pid 31378 for 59s running: bin/fm-test-run.sh tests/fm-test-run.test.sh [in ~/.treehouse/firstmate-7077c2/3/firstmate]
fm-build-lock: still waiting 2m00s for the machine-wide build lock - position 3 of 3 in line - held by pid 31378 for 1m59s running: bin/fm-test-run.sh tests/fm-test-run.test.sh [in ~/.treehouse/firstmate-7077c2/3/firstmate]
fm-build-lock: still waiting 3m00s for the machine-wide build lock - position 3 of 3 in line - held by pid 31378 for 2m59s running: bin/fm-test-run.sh tests/fm-test-run.test.sh [in ~/.treehouse/firstmate-7077c2/3/firstmate]
fm-build-lock: still waiting 4m00s for the machine-wide build lock - position 2 of 2 in line - held by pid 39553 for 30s running: bash tests/fm-build-lock.test.sh [in /private/tmp/claude-501/-Users-marano--treehouse-firstmate-7077c2-3-firstmate/f3735aa4-2b4b-4bd1-96a0-a7134828b6b9/scratchpad/w2-hold-drop]
fm-build-lock: still waiting 5m00s for the machine-wide build lock - position 1 of 2 in line - held by pid 50676 for 46s running: python3 /private/tmp/claude-501/-Users-marano--treehouse-bluejam-platform-f6906d-2-bluejam-platform/0ad3dde2-6bbd-4efc-a1b9-37b482dd2300/scratchpad/mutants.py [in ~/.treehouse/bluejam-platform-f6906d/2/bluejam-platform]
fm-build-lock: still waiting 6m00s for the machine-wide build lock - position 1 of 2 in line - held by pid 50676 for 1m46s running: python3 /private/tmp/claude-501/-Users-marano--treehouse-bluejam-platform-f6906d-2-bluejam-platform/0ad3dde2-6bbd-4efc-a1b9-37b482dd2300/scratchpad/mutants.py [in ~/.treehouse/bluejam-platform-f6906d/2/bluejam-platform]
fm-build-lock: acquired the machine-wide build lock after 6m32s
ok - inbox: a steer is written durably and round-trips byte-exact with a self-describing doorbell
ok - inbox: a hostile-path doorbell executes as a no-op in bare shells
ok - inbox: terminal-control paths are rejected without typing
ok - inbox: the ring skips dead or missing endpoints and still rings live or unclassifiable endpoints
ok - inbox: the ring reports a doorbell the idle composer never submitted
ok - inbox: the idempotent enqueue dedups an exact re-run onto the same record, handled or not
ok - inbox: idempotent enqueue follows a record concurrently moved to handled
ok - inbox: the handled mv is the idempotent ack and sequences are never reissued
ok - inbox: concurrent writers serialize on the sequence lock and lose nothing
ok - inbox: a writer retries when a competing lock vanishes after its failed claim
ok - inbox: ladder bookkeeping ignores a concurrently removed inbox
ok - inbox: fire-and-forget records stay durable and outside the ladder
ok - inbox: the re-ring ladder paces by grace, escalates once, and resets on ack
ok - inbox: the ladder names a composer stuck through the whole budget, and only that
ok - watcher: an unhandled aged message on an idle pane re-rings without waking firstmate, and the ack silences it
ok - watcher: a busy pane just waits - the record is durable and no doorbell is typed
ok - watcher: a healthy or empty inbox stays completely silent
ok - watcher: acknowledgement silences an unwritable ladder without a stale wake
ok - watcher: unwritable ladder bookkeeping surfaces a stale wake after the doorbell
ok - watcher: a spent ring budget emits exactly one ordinary stale wake for recovery
ok - watcher: a live idle worker whose composer never submits is named as unable to receive messages
ok - watcher: claude idle with a stranded queued doorbell is named as unable to receive messages
ok - watcher: a busy worker whose queued text will still submit is never rung into or alarmed on
ok - watcher: a positively dead pane is never typed into and surfaces exactly one stale wake
ok - watcher: dead-pane recovery overrides stale busy state
Evidence: composer-lib test output

Source: composer-lib test output

fm-build-lock: waiting for the machine-wide build lock - this process is WAITING, not wedged (held by pid 39933 for 5s running: bin/fm-test-run.sh tests/fm-test-fixtures.test.sh [in ~/.treehouse/firstmate-7077c2/3/firstmate])
fm-build-lock: acquired the machine-wide build lock after 16s
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, ⟩ muse) read empty bordered or bare
ok - fm_composer_classify_content: an empty composer reads empty
ok - fm_composer_classify_content: idle matching is limited to proven placeholder positions
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)
ok - matrix: claude's ❯+NBSP row reads empty on every profile in both locales (#1988)
ok - matrix: codex's dim hint is empty when styling proves it, unknown (never pending) when it cannot
ok - matrix: muse's ⟩ reads empty everywhere and survives losing the styled-glyph signal
ok - matrix: cursor's reverse-video placeholder remnant reads empty; real typed text stays pending
ok - matrix: herdr half-block rules bound a bare composer's wrap region
ok - matrix: omp's status row bounds the bare composer's wrap region
ok - matrix: codex 0.154's starfield rows are furniture; typed, mixed, and unanchored rows keep their verdicts
ok - matrix: pi's separated composer needs identity + structure; the blank row alone never proves it
ok - matrix: opencode's left-bar composer reads empty everywhere and scans the full active run
ok - matrix: grok's real oversized titled bottom is empty while typed and unproved panes stay safe
ok - matrix: kimi's bordered shell-glyph box reads empty through the shared owner (spawn's fourth copy retired)
ok - matrix: the real claude-in-zellij --ansi dump reads empty in both locales
ok - matrix: claude's queued, unsent input reads pending on every profile, from either signal
ok - strict posture: blank and unidentified rows are unknown, never injectable empty
ok - fm_composer_classify_screen: the bare composer's wrap region stays identified; structure breaks it
ok - fm_composer_classify_screen: a row-leading agent glyph reanchors the live composer
ok - fm_composer_classify_screen: a lower dead shell invalidates only cursorless stale composers
ok - fm_composer_classify_screen: cursorless bare wrap regions participate in verdicts
ok - fm_composer_classify_screen: cursorless containers reject only contiguous unclaimed activity
ok - fm_composer_classify_screen: the bottom-most candidate wins; stale banners cannot
ok - fm_composer_classify_screen: incomplete lower structure invalidates stale boxes
ok - fm_composer_classify_screen: titled bottoms retain full box geometry
ok - fm_composer_classify_screen: a proven box tolerates a bottom-border cursor
ok - fm_composer_extract_selected_content: scopes user content and excludes furniture
ok - fm_composer_queued_enter_verdict: pending + busy returns empty (queued Enter)
ok - fm_composer_queued_enter_verdict: pending + idle/unknown stays pending
ok - fm_composer_queued_enter_verdict: only proven pending is converted
ok - fm_composer_holds_text: proves the caller's own wrapped, mark-stripped text where the classifier reads unknown
ok - fm_composer_holds_text: refuses other, extra, moved, echoed, bordered, and shell-anchored text

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 5 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A live idle worker whose composer never submits is named as unable to receive messages, not a generic stale wake ✅ pass live fm-task-inbox.tap: 'watcher: a live idle worker whose composer never submits is named as unable to receive messages'
A claude worker idle with a stranded queued doorbell is named as unable to receive messages ✅ pass live fm-task-inbox.tap: 'watcher: claude idle with a stranded queued doorbell is named as unable to receive messages'
A busy worker whose queued text will still submit is never rung into or alarmed on (refusal case) ✅ pass live fm-task-inbox.tap: 'watcher: a busy worker whose queued text will still submit is never rung into or alarmed on'
The re-ring ladder names only a composer stuck through the whole budget ✅ pass live fm-task-inbox.tap: 'inbox: the ladder names a composer stuck through the whole budget, and only that'
The composer classifier proves the caller's own text is held in the composer (fm_composer_holds_text) ✅ pass live fm-composer-lib.tap: 'fm_composer_holds_text' cases
Live real-agent doorbell e2e (fm-send-inbox-doorbell-live-e2e.test.sh) ⏸️ untested no Needs FM_SEND_INBOX_LIVE_E2E=1 plus installed harnesses (claude, codex) and spends real model tokens. Opt in to run it.
fm-spawn.sh reports success on a garbled launch, and spawn/relaunch do not clear a shell continuation prompt ⏸️ untested no This change does not touch fm-spawn.sh or fm-control.sh relaunch. Only the spawn-related commit (2f8f630) is in the base. Nothing here to drive.
  • bash tests/fm-composer-lib.test.sh (0 failures)
  • bash tests/fm-task-inbox.test.sh (0 failures)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…nging behind it

A steer's doorbell rung while a claude worker is mid-turn waits in claude's
queue above the composer, and the composer row shows only a dim placeholder
that ghost stripping removes, so the pane read empty. When that queue was
stranded on an idle worker, every watcher re-ring queued another doorbell
behind it and the ladder ended in a generic stale wake that left the
diagnosis to reading the pane by eye.

The composer classifier now reads claude's queued, unsent input as pending
from either of two signals: the queued-message placeholder on the composer
row, or the send-now hint above it. The doorbell ring reports text it typed
that the idle composer still holds, and the re-ring ladder counts attempts
that found the composer holding unsent text. When every attempt of the budget
did, the watcher raises a distinct stale wake saying the worker cannot
receive messages. A busy worker whose queued text will still submit is never
rung into or alarmed on, and nothing is cleared automatically.
@marano
marano merged commit ba4ec8e into main Sep 19, 2026
16 checks passed
@marano
marano deleted the fm/fm-pane-delivery-doorbell branch September 19, 2026 19:18
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