feat(agents): deliver to busy agents through a bounded queue - #239
Merged
Merged
Conversation
…into the resume line Waking a hibernated node re-launches its conversation with `--resume`, and between the launch line's first byte and its submit sits "the pane's most fragile moment": anything the human types is spliced into the command and the conversation is lost. Until now the keyboard path wrote straight to the transport throughout the wake — `wakeInFlightRef` and the `hibernated` re-read never touched it. cmux's bounded buffer (TerminalPanel.swift:737-740) is the missing piece. WakeInputBuffer is a pure state machine: passthrough when idle, HOLD (in order) while a wake is in flight, flush on a confirmed `resumed` (which means the resume line already submitted), and DROP to `expired` on any other end — the pane became something we did not resume into, so the held bytes must not be written. The bound is exact and overflow answers `queueFull` (never a silent drop), matching the message queue's vocabulary. Wired into TerminalNode's `term.onData` (hold), the wake `.then`/`.catch` (flush/drop), and the session-scoped `restartIo.write` (the lifetime-gated writer). Every edge is mutation-checked: the expired-drop guard, the exact bound, and the waking gate each turn a test red when reverted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ver a silent drop Gate 2 refuses a BUSY target, and Eco hibernation leaves an idle node's pane on a shell so gate 1 refuses it forever — exactly the nodes a long orchestration is most likely to message. "Retry in a moment" hands a language model a busy-loop behind a rate limiter. So a permitted delivery that refuses only because the target is busy (or hibernated) is now ENQUEUED and delivered when the target next goes idle. DeliveryQueue is a pure src/core module (no electron), bounded per target (refuse-when-full, never drop-oldest) with a per-entry TTL. A TTL lapse emits `expired` to BOTH the trace and the sender — never a silent drop. `queued` is not `delivered`: the bytes have not reached the pane, and the receipt closes the loop once the flush delivers them. A hibernated target is woken through the registry before delivery. The load-bearing property is flush-time re-validation: the flush's `deliver` is `runDelivery`, the same end-to-end path the verb takes, so ownership, grant and flow are re-checked against LIVE state per queued message. A grant revoked while a message was queued comes back `notPermitted` at flush and the message is dropped, not delivered — pinned by a test that flips the injected outcome between enqueue and flush. The queue gates on NODE TYPE, not a runtime probe: the DECSET-2004 measurement (2026-08-15) found all four agent CLIs keep bracketed paste ON at idle AND during startup, so `paste-buffer -p` frames every flush; the one unsafe surface is a non-agent pane, which only ever queues via the hibernated leg (a shell that becomes an agent after the wake) and never a genuine terminal. Wired into deliverFromControl (enqueue on busy/hibernated) and onMessagingAgentEvent (flush on the target's `done`). Every rule is mutation-checked: the capacity bound, the flush-time drop of a revoked grant, the two-legged expiry, and the wake-on-enqueue each turn a test red when reverted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he design Two things the design knew and the code did not say. First, the residual risk no gate closes: hook-idle is not the same as nobody typing — a human's half-composed draft in a genuinely idle agent's composer gets the message appended and the Enter submits both. That is the literal 2026-07-16 objection, undetectable from hooks; G4 and the trace are what make it diagnosable rather than invisible. Second, the not-in-v1 decision: the deliver-on-idle queue is in-memory, and its existence must not imply a persisted one. Catch-up after restart is not built — a persisted queue needs the same TTL and a "stale — the sender may have moved on" marker, because --resume can land a message in a conversation that no longer expects it, and whether a stale-marked message beats none is a prompt question, unverified, to be measured before that queue is built. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd to end The owner's requirement as an executable guarantee. One long test, not five unit tests, because the chain has five links and every one of them has been believed-and-wrong at some point in this feature's history — a suite of isolated units would have passed while rev.3 stated the opposite of what the code does. The five links, real end to end on a host with bash>=5.1 / tmux / curl: (1) the token comes from the PERSIST sweep (refreshNodeTokens), never the spawn path; (2) a phone-shaped spawn with exactly HookEnv.flags' two vars; (3) the REAL generated managed script POSTs to a real node:http listener and its token verifies under the real secret; (4) the mirror records that same verdict and the pure decider admits it; (5) deliverAgentMessage lands the envelope in a REAL tmux pane, read back with capture-pane. Mutation-checked: weakening gate 2 breaks the loud tests, and no-oping the persist sweep breaks link 1. Two future regressions fail loudly instead of going dark: narrowing token materialisation to the spawn path (targetStatusUnverified, not retryable), and a target left on a pre-identity hook script (targetHookScriptStale). Per the brief, if this cannot pass the answer is never to weaken gate 2 — a "the host knows this node id" trust level is true of the victim too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nwired forget() PR review round 1. I1 (Important): the production queue was built with only now/deliver/trace, so a busy-queued message that TTL-expired was recorded solely in the in-memory trace ring — never board-logged and never surfaced where the sender's operator looks. Task 7.2 requires emitting `expired` to the sender AND to the trace, so build it: a new `createDeliveryQueue(deps)` service factory wires both legs in one testable place — the trace leg (ring, plus the target project's board log when its runtime ownership is resolvable) and the SENDER leg (onExpired/onFlushed → a board-log line in the sender's own project). main/index.ts now builds the queue through the factory. A new mutation-checked test proves a TTL-expired busy-queued message reaches the sender's board log, with the target deliberately unresolvable so the sender leg is isolated: dropping onExpired turns it red. wake/isHibernated stay unsupplied on the desktop — the hibernated leg's main→renderer signal does not exist yet — and remain an explicitly-recorded residual in the code and the PR body. The busy-target leg is now genuinely wired end to end: enqueue on busy, flush on the target's `done`, and expiry surfaced to the sender. M1 (Minor): forget() was defined + unit-tested but wired nowhere (its only clean call site is core session teardown, which cannot reach a main-wired queue without new plumbing). Flush-time re-validation already makes a stale queued entry safe — a deleted node never re-emits `done`, and TTL expiry bounds it — so the dead method and its test are removed rather than left with only a test for a reader. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eneskirca
enabled auto-merge
August 15, 2026 19:48
This was referenced Aug 15, 2026
pull Bot
pushed a commit
to jasonkneen/nodeterm
that referenced
this pull request
Aug 16, 2026
…refused PR 7 (bounded deliver-on-idle queue, eneskirca#239 merged) changed a busy target from a hard `targetBusy` refusal to an enqueue-and-deliver-on-idle, but the agent-facing verb help never got the memo — both `buildCanvasSkillBody` (the manage-nodeterm-canvas SKILL.md, regenerated into every Claude config dir by installCanvasSkillInto) and `buildCanvasControlInstructions` (the codex/gemini/copilot/opencode AGENTS.md block) still said 'delivered only when verifiably idle — a busy target answers targetBusy instead of being interrupted'. An orchestrating agent reading that polls or gives up instead of trusting the queue, and it contradicts the RETRYABLE table the same skill already renders. Both texts now describe the queue (queued/delivered/expired/queueFull); the retry guidance stays sourced from the RETRYABLE table, not re-typed. A new test walks both generated bodies and reddens on a revert to the pre-PR-7 sentence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
PR 7, the final PR of the agent-messaging series: delivery to BUSY and hibernated agents through a bounded, per-target queue with a TTL — plus the human-side wake buffer that makes the same fragile moment safe, and the owner's phone-target acceptance gate.
Built on
feat/messaging-per-project-switch(#237): origin/main does not yet carry the runtime pane-ownership ledger (src/core/agents/pane-ownership.ts), which PR 7's flush-time re-validation depends on, so this branches off #237 as the brief instructs. Retarget onto main once #237 merges.Tasks → commits
feat(terminal): a bounded input buffer during wake …feat(messaging): deliver-on-idle — a bounded queue with a TTL …docs(messaging): record the residual risks in the code …test(messaging): a phone-spawned session is a valid message target, end to endThe DECSET-2004 measurement gates the node-type decision
The queue delivers on idle WITHOUT a per-delivery bracketed-paste probe because the 2026-08-15 measurement (this host, twice each, no flapping) found all four agent CLIs — claude, codex, gemini, opencode — keep DECSET 2004 ON at idle AND during startup, so
paste-buffer -pframes every flush, including one into a still-starting agent after a wake. The one unsafe surface is a NON-agent pane (2004 OFF ⇒ raw keystrokes, one submit per newline). So the queue gates on NODE TYPE, not a runtime probe: it only ever enqueues for a target whose delivery already refused astargetBusy, or a hibernated node whose shell pane becomes an agent after the wake — never a genuine terminal. The flush re-runs gate 1, so a pane that became a terminal while queued is refused, not sprayed.Flush-time re-validation (the load-bearing property)
A queued message trusts nothing about why it was queued. The queue's
deliverdep isrunDelivery— the same end-to-end path the verb takes (scope → ownership → grant → flow →deliverAgentMessage) — so ownership, grant and flow are re-checked against LIVE state on every flush. A grant revoked while a message was queued comes backnotPermittedat flush and the message is dropped, not delivered. Pinned by a test that flips the injected outcome between enqueue and flush.Discipline
TDD throughout; every key assertion is mutation-checked (revert the mechanism → red → restore): the wake buffer's expired-drop guard / exact bound / waking gate; the queue's capacity bound / revoked-grant drop / two-legged expiry / wake-on-enqueue; and the acceptance gate itself (weakening gate 2 breaks its loud tests, no-oping the persist sweep breaks its link 1).
src/corestays free of electron (no-electron.test.tsgreen).Device verification owed
wakeandisHibernateddeps are RENDERER state (Eco lives inuseAgentStatus, the wake registry in the renderer'sagent-restart), and main has no signal for either today. The BUSY-target leg is fully wired and works end to end in main (enqueue ontargetBusy, flush on the target'sdoneevent). The hibernated leg is complete in core + service and unit-tested, but its production main→renderer wake IPC + hibernation read is owed to device verification. The Task 7.4 acceptance gate itself does not exercise hibernation — it proves the phone-spawned-target chain end to end on a real host.🤖 Generated with Claude Code