fleetd #761: hold a lead nudge while the operator's prompt box holds a draft #765

Closed
agent wants to merge 0 commits from worker/761-draft-aware-lead-nudge-d05850-4 into main
Member

Closes fleet/fleetd#761.

The defect

AgentControl.send is herdr's agent.prompt, which pastes the text and presses Enter in the same call. A nudge arriving while the operator is mid-sentence therefore submits their unfinished line with the nudge glued to it. The lost keystrokes are the symptom; the forced submit is the harm.

AgentStatus.injectable() cannot stop this. It describes the agent, and an idle agent reports idle whether its input box is empty or holds a half-typed line.

The fix

New herdr/PromptBox:

  • reads the pane's detection region — the prompt/footer tail herdr uses for status detection, and the region the input box is drawn in. visible / recent* carry transcript scrollback whose own text can look like a prompt box.
  • clears a delivery only when the box is positively empty. A box with characters, a pane it cannot recognise, and a failed read all hold: a held nudge is recoverable, a submitted half-line is not.
  • treats whitespace and a cursor block (#-style block glyphs) in the box as empty. Anything else is the operator's text, including a placeholder hint a future TUI might draw there — that direction holds a nudge it could have sent, which the warning below makes visible.
  • takes the last | > box marker on the pane, so an earlier box render in the capture does not win.
  • holds on an active-turn marker (esc to interrupt) even with a box present.

The never-resolving draft. After 20 consecutive holds for one target it logs one WARN naming the target, the streak, the state and the character count — not the text, which could be anything the operator typed. It never forces the send: forcing it is the exact harm. The warning repeats only after the box has cleared again.

Wiring

Path Held as
ReplyPushLoop.decide WAIT_BUSY — the pending work is re-read on the next tick
LeadHeartbeatLoop.tick a new DRAFT_HELD action that spends neither the quiet budget nor the one context notice per HIGH stretch
LeadCoordLoop.tick the peer message stays held and unacked

Deliberately not inject/Injector: no human types into a spawned member's pane, so it would buy nothing and cost a herdr agent.read per member poll. InjectorTest.deliveringToAMemberReadsNoPane pins that. The heartbeat reads the pane only for a tick that would otherwise send.

PromptBox lives in herdr, a leaf package, rather than next to StatusRefiner in inject: msg <-> inject is a recorded cycle in PackageCyclesTest, and a new edge would widen it. msg -> herdr is one-way and unbaselined.

Tests

FakeHerdr gains detectionText(), because one readText cannot be both a worker's transcript (what the completion scrape reads from recent) and a lead's empty prompt. It falls back to readText, so no existing fixture changes meaning. Fixtures on the lead-nudge paths now state what their pane shows, because the behaviour depends on it.

24 new tests: 13 for the classifier and the gate, 10 behavioural across the three loops, 1 for the member path.

Each of the 10 loop tests was run against the unpatched loops and fails there, for example:

ReplyPushLoopTest.aLeadWithUnsubmittedTextInItsPromptBoxIsNotNudged
  a nudge pastes and submits, so an idle lead mid-sentence must not be nudged
  ==> expected: <WAIT_BUSY> but was: <INJECT>
ReplyPushLoopTest.aNudgeHeldForADraftIsSentOnALaterTick
  every tick holds while the operator is typing ==> expected: <0> but was: <5>
LeadCoordLoopTest.aLeadWithUnsubmittedTextInItsPromptBoxKeepsTheMessageHeldAndUnacked
  delivery pastes and submits, so it must not land on a half-typed line
  ==> expected: <0> but was: <1>
LeadHeartbeatLoopTest.tickHoldsTheNudgeWhileTheLeadsPromptBoxHoldsUnsubmittedText
  a nudge pastes and submits, so it must not land on a half-typed line
  ==> expected: <0> but was: <1>

PromptBoxTest cannot exist before the change — at HEAD the compile fails with package PromptBox does not exist. InjectorTest.deliveringToAMemberReadsNoPane passes both ways by design: it pins an absence.

No new source-text test was added.

Build

cd fleetd && mvn clean install, unpiped and redirected to a file:

MVN_EXIT=0
[INFO] BUILD SUCCESS
Tests run: 2161, Failures: 0, Errors: 0, Skipped: 0

The count is summed from target/surefire-reports/*.xml, not read off the log.

Not done

  • No config knob — the old behaviour is wrong for every operator.
  • agent.prompt still submits; a paste without Enter would never wake an idle lead, which is ReplyPushLoop's whole purpose.
  • The residual race between the read and the paste remains, as the ticket notes. It is now a small window instead of the default case.
  • The CLAUDE.md <-> wiki/7-Use-Cases.md sync check could not be run: wiki/ is uninitialised in a worker's worktree. It is not reported as passed.
Closes fleet/fleetd#761. ## The defect `AgentControl.send` is herdr's `agent.prompt`, which **pastes the text and presses Enter in the same call**. A nudge arriving while the operator is mid-sentence therefore submits their unfinished line with the nudge glued to it. The lost keystrokes are the symptom; the forced submit is the harm. `AgentStatus.injectable()` cannot stop this. It describes the *agent*, and an idle agent reports `idle` whether its input box is empty or holds a half-typed line. ## The fix New `herdr/PromptBox`: * reads the pane's `detection` region — the prompt/footer tail herdr uses for status detection, and the region the input box is drawn in. `visible` / `recent*` carry transcript scrollback whose own text can look like a prompt box. * clears a delivery **only** when the box is positively empty. A box with characters, a pane it cannot recognise, and a failed read all **hold**: a held nudge is recoverable, a submitted half-line is not. * treats whitespace and a cursor block (`#`-style block glyphs) in the box as empty. Anything else is the operator's text, including a placeholder hint a future TUI might draw there — that direction holds a nudge it could have sent, which the warning below makes visible. * takes the **last** `| >` box marker on the pane, so an earlier box render in the capture does not win. * holds on an active-turn marker (`esc to interrupt`) even with a box present. **The never-resolving draft.** After 20 consecutive holds for one target it logs one `WARN` naming the target, the streak, the state and the character count — not the text, which could be anything the operator typed. It never forces the send: forcing it is the exact harm. The warning repeats only after the box has cleared again. ## Wiring | Path | Held as | |---|---| | `ReplyPushLoop.decide` | `WAIT_BUSY` — the pending work is re-read on the next tick | | `LeadHeartbeatLoop.tick` | a new `DRAFT_HELD` action that spends neither the quiet budget nor the one context notice per HIGH stretch | | `LeadCoordLoop.tick` | the peer message stays held **and unacked** | Deliberately **not** `inject/Injector`: no human types into a spawned member's pane, so it would buy nothing and cost a herdr `agent.read` per member poll. `InjectorTest.deliveringToAMemberReadsNoPane` pins that. The heartbeat reads the pane only for a tick that would otherwise send. `PromptBox` lives in `herdr`, a leaf package, rather than next to `StatusRefiner` in `inject`: `msg` <-> `inject` is a recorded cycle in `PackageCyclesTest`, and a new edge would widen it. `msg` -> `herdr` is one-way and unbaselined. ## Tests `FakeHerdr` gains `detectionText()`, because one `readText` cannot be both a worker's transcript (what the completion scrape reads from `recent`) and a lead's empty prompt. It falls back to `readText`, so no existing fixture changes meaning. Fixtures on the lead-nudge paths now state what their pane shows, because the behaviour depends on it. 24 new tests: 13 for the classifier and the gate, 10 behavioural across the three loops, 1 for the member path. **Each of the 10 loop tests was run against the unpatched loops and fails there**, for example: ``` ReplyPushLoopTest.aLeadWithUnsubmittedTextInItsPromptBoxIsNotNudged a nudge pastes and submits, so an idle lead mid-sentence must not be nudged ==> expected: <WAIT_BUSY> but was: <INJECT> ReplyPushLoopTest.aNudgeHeldForADraftIsSentOnALaterTick every tick holds while the operator is typing ==> expected: <0> but was: <5> LeadCoordLoopTest.aLeadWithUnsubmittedTextInItsPromptBoxKeepsTheMessageHeldAndUnacked delivery pastes and submits, so it must not land on a half-typed line ==> expected: <0> but was: <1> LeadHeartbeatLoopTest.tickHoldsTheNudgeWhileTheLeadsPromptBoxHoldsUnsubmittedText a nudge pastes and submits, so it must not land on a half-typed line ==> expected: <0> but was: <1> ``` `PromptBoxTest` cannot exist before the change — at HEAD the compile fails with `package PromptBox does not exist`. `InjectorTest.deliveringToAMemberReadsNoPane` passes both ways by design: it pins an absence. No new source-text test was added. ## Build `cd fleetd && mvn clean install`, unpiped and redirected to a file: ``` MVN_EXIT=0 [INFO] BUILD SUCCESS Tests run: 2161, Failures: 0, Errors: 0, Skipped: 0 ``` The count is summed from `target/surefire-reports/*.xml`, not read off the log. ## Not done * No config knob — the old behaviour is wrong for every operator. * `agent.prompt` still submits; a paste without Enter would never wake an idle lead, which is `ReplyPushLoop`'s whole purpose. * The residual race between the read and the paste remains, as the ticket notes. It is now a small window instead of the default case. * The `CLAUDE.md` <-> `wiki/7-Use-Cases.md` sync check **could not be run**: `wiki/` is uninitialised in a worker's worktree. It is not reported as passed.
agent added 1 commit 2026-10-05 10:50:57 +02:00
fleetd #761: hold a lead nudge while the operator's prompt box holds a draft
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 57s
CI / build (pull_request) Failing after 2m13s
9084667493
herdr's agent.prompt pastes AND submits in one call, so a nudge arriving
while the operator is mid-sentence submitted their unfinished line with the
nudge glued to it. AgentStatus.injectable() cannot see this: it describes
the agent, and an idle agent reports the same status whether its input box
is empty or holds a half-typed line.

New herdr/PromptBox reads the pane's `detection` region — the same region
StatusRefiner uses, and the one the input box is drawn in — and clears a
delivery only when the box is positively empty. A box with characters, a
pane it cannot recognise, and a failed read all hold, because a held nudge
is recoverable and a submitted half-line is not. Whitespace and a cursor
block count as empty. After 20 consecutive holds for one target it logs one
warning, so a box that never clears is visible rather than silent; the
warning repeats only after the box has cleared again.

Wired into the three paths that nudge a lead's own pane:
  - ReplyPushLoop.decide -> WAIT_BUSY (the pending work is re-read next tick)
  - LeadHeartbeatLoop.tick -> a new DRAFT_HELD action that spends neither the
    quiet budget nor the one context notice per HIGH stretch
  - LeadCoordLoop.tick -> the peer message stays held and unacked

Not wired into inject/Injector: no human types into a spawned member's pane,
so it would buy nothing and cost a herdr agent.read per member poll. The
heartbeat reads the pane only for a tick that would otherwise send.

Tests. FakeHerdr gains detectionText(), because one readText cannot be both
a worker's transcript (what the completion scrape reads) and a lead's empty
prompt; it falls back to readText so no existing fixture changes meaning.
Fixtures for the lead-nudge paths now state what their pane shows, since the
behaviour depends on it. 11 new behavioural tests across the three loops plus
InjectorTest, and 13 for the classifier; all 10 loop tests were run against
the unpatched loops first and fail there.

mvn clean install: BUILD SUCCESS, Tests run: 2161, Failures: 0, Errors: 0,
Skipped: 0 (summed from target/surefire-reports).
agent added 1 commit 2026-10-05 11:07:47 +02:00
fleetd #761: read the input box marker the live TUI actually draws
CI / shell-tests (pull_request) Failing after 10s
CI / contract (pull_request) Successful in 51s
CI / build (pull_request) Failing after 2m1s
4e414c475f
The gate matched the box line as "│ >", which appears zero times on a current
Claude Code pane. EMPTY was unreachable, so every lead nudge was held forever.

Match the box as a line *starting* with "❯" or with "│ >", keeping the older
bordered layout readable. A marker further along a line is transcript text — a
caret the operator quoted — so it no longer counts, and the last matching line
is still the live box because the detection region carries scrollback above it.

Look for the generating marker only from the box line down, for the same
reason: an earlier turn's "esc to interrupt" survives in that scrollback, and
holding on it would be the same unreachable-EMPTY failure by another route.

Fixtures: add IDLE_PROMPT_CARET / DRAFTED_PROMPT_CARET from a live pane and
point every lead-pane fake at them. The bordered constants stay, now covering
the older client. Against the old marker these fixtures fail 55 tests across
PromptBoxTest and the three loop tests, which is the production bug reproduced.

cd fleetd && mvn clean install -> BUILD SUCCESS, MVN_EXIT=0,
Tests run: 2164, Failures: 0, Errors: 0, Skipped: 0 (179 surefire XML files).
ltms closed this pull request 2026-10-05 11:21:04 +02:00
Some checks are pending
CI / shell-tests (pull_request) Failing after 10s
CI / contract (pull_request) Successful in 51s
CI / build (pull_request) Failing after 2m1s

Pull request closed

Sign in to join this conversation.