Skip to content

feat(agents): deliver to busy agents through a bounded queue - #239

Merged
eneskirca merged 6 commits into
mainfrom
feat/messaging-bounded-queue
Aug 15, 2026
Merged

eneskirca merged 6 commits into
mainfrom
feat/messaging-bounded-queue

Conversation

@eneskirca

Copy link
Copy Markdown
Owner

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

Task Commit
7.1 — the bounded wake input buffer (build first) feat(terminal): a bounded input buffer during wake …
7.2 — deliver-on-idle: the bounded per-target queue with a TTL feat(messaging): deliver-on-idle — a bounded queue with a TTL …
7.3 — record the residual risks in the code docs(messaging): record the residual risks in the code …
7.4 — ACCEPTANCE GATE: a phone-spawned session is a valid target test(messaging): a phone-spawned session is a valid message target, end to end

The 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 -p frames 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 as targetBusy, 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 deliver dep is runDelivery — 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 back notPermitted at 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/core stays free of electron (no-electron.test.ts green).

Device verification owed

  • codex composer-idle — the DECSET measurement inferred codex's composer-idle 2004 from process-wide 2004 at TUI entry (its trust dialog could not be answered without persisting host config). One Enter on an already-trusted host closes it.
  • The hibernated-target leg's renderer→main wiring — the queue's wake and isHibernated deps are RENDERER state (Eco lives in useAgentStatus, the wake registry in the renderer's agent-restart), and main has no signal for either today. The BUSY-target leg is fully wired and works end to end in main (enqueue on targetBusy, flush on the target's done event). 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

eneskirca and others added 5 commits August 15, 2026 22:05
…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
eneskirca enabled auto-merge August 15, 2026 19:48
@eneskirca
eneskirca merged commit c4b0ae9 into main Aug 15, 2026
4 checks passed
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>
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