Compare commits

..

46 Commits

Author SHA1 Message Date
Dai Ha 66ab9cab24 fleetd #791: addendum — plugin 0.3.0 has no mount and is installed in four instances
CI / shell-tests (push) Failing after 8s
CI / contract (push) Successful in 49s
CI / build (push) Failing after 1m59s
2026-10-06 08:03:50 +02:00
Dai Ha f611488c9b fleetd #791: README — the mod still reads FLEETD_MCP_URL as an optional override
CI / shell-tests (push) Failing after 8s
CI / contract (push) Successful in 56s
CI / build (push) Failing after 2m4s
2026-10-06 08:03:06 +02:00
Dai Ha 44d16e7639 fleetd #791: merge PR #794 — fleet plugin 0.3.0, no MCP mount, mod off for members
The plugin no longer carries .mcp.json; the instance or the project mounts
fleet. The mod asks fleet_whoami once and skips the fleet_inbox poll for a
worker or an architect, so members keep paste delivery. The mod reads
FLEETD_MCP_URL with $.env.get (documented in the mods API), defaulting to
http://127.0.0.1:8765/mcp.

Lead verification on 87f7c03: claude plugin validate plugin passed;
claude plugin test plugin 21 pass, 0 fail. With the role gate removed, the
worker and architect tests fail (19 pass, 2 fail), so they are not vacuous.
2026-10-06 08:03:01 +02:00
Dai Ha 87f7c03535 fleetd #791: fleet plugin 0.3.0 — drop the MCP mount, gate the mod off for members
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 53s
CI / build (pull_request) Failing after 2m7s
- Delete plugin/.mcp.json; mounting fleet is now the instance's or the
  project's job, not the plugin's. The setup skill still writes the
  project .mcp.json entry.
- register.js: read FLEETD_MCP_URL via $.env.get, defaulting to
  http://127.0.0.1:8765/mcp, documented at
  https://code.claude.com/docs/en/plugins/mods/api.md ("$.env — get
  and set environment variables").
- Gate the fleet_inbox poll by role: a worker or architect learns its
  role once from fleet_whoami and then never polls fleet_inbox; every
  other role polls as before. An unreachable daemon retries whoami on
  a later tick rather than deciding.
- Bump 0.2.0 -> 0.3.0 in plugin.json and marketplace.json, and update
  README/SKILL.md to drop every claim that the plugin mounts fleetd.
- Add 6 tests for the role gating; all 21 tests and
  `claude plugin validate` pass.
2026-10-06 06:46:26 +02:00
Dai Ha 562354a58d fleetd #790: document observer -> lead send in the bridge block
CI / shell-tests (push) Failing after 9s
CI / contract (push) Successful in 51s
CI / build (push) Failing after 1m59s
Invariant 3, the observer paragraph and the unconfigured-pane row now say an
observer may send to a lead. Invariant 4 now says a direct send to a lead pane
is not box-gated yet (#793). Wiki template synced (check printed in sync: True).
2026-10-06 06:25:31 +02:00
Dai Ha eaa1f0d170 fleetd #790: merge PR #792 — an observer pane may fleet_send to a lead
CI / shell-tests (push) Failing after 7s
CI / contract (push) Successful in 56s
CI / build (push) Failing after 2m5s
An observer's SEND classifier (CallerResolver.observerSendTarget, renamed from
sendableObserverTarget) now accepts a configured lead terminal as well as
another observer pane. Collaborators, architect slots and spawned members stay
refused. The [fleet_send from observer term_…] prefix is kept. An observer
learns a lead's sessionId from its filtered fleet_list panes rows (role "lead").

Lead verification: mvn clean install on 72ea7de in a throwaway worktree,
exit 0, surefire totals 2208 run / 0 failures / 0 errors / 0 skipped.

Follow-up found in review: #793 (the Injector has no prompt-box gate for a
direct send to a lead pane).
2026-10-06 06:24:33 +02:00
Dai Ha 72ea7def0a fleetd #790: let an observer pane fleet_send to a lead
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 54s
CI / build (pull_request) Failing after 2m7s
An observer could only reach another observer pane, so a hand-opened tab
could answer a lead with fleet_reply but never start a conversation with
one.

CallerResolver.sendableObserverTarget() is renamed observerSendTarget()
and now accepts a configured lead terminal as well as a pane that falls
to the observer floor. It reads the same maps resolve() reads, in the
same order: a live spawned member is refused first, a lead is then
accepted even when the same pane is also bound to an architect slot,
and a collaborator or an architect-slot pane is refused. A collaborator,
an architect and a spawned member stay unreachable.

Authz keeps its one SEND case; only the classifier it is handed widened,
so the MCP gate (FleetMcp.denyFor) and the REST gate (FleetApp.allow)
give the same answer from the same predicate.

fleet_list shows the lead's address in the observer's filtered `panes`
rows rather than by adding the `leads` array: the panes filter already
reads the same classifier as the SEND gate, so reachability and
visibility cannot drift, and the reduced row carries no lead name,
context window or config dir.

Gates traced end to end for an observer -> lead send: denyFor, the
sendTool argument validation (profileTargetError rejects profile names
only), MessageService (records the caller's owner key, no role gate),
the injector's readiness gate (Fleetd.deliverableTo accepts a lead
through the leads map, never a presence entry) and its status gate.
Unchanged: an observer still holds no TASK_READ, so it cannot poll the
ticket a wait:false send returns.

Tests: observer -> lead allowed and observer -> collaborator / architect
slot / spawned member refused over denyFor, each with a positive control
in the same test; the same matrix over the REST route; a real MCP client
through the real MessageService and Injector to the real herdr call,
proving the [fleet_send from observer term_...] prefix reaches a lead's
pane and that the lead passes the production readiness gate; and the
observer's fleet_list panes rows now carrying the lead.

mvn clean install in fleetd/: Tests run: 2208, Failures: 0, Errors: 0,
Skipped: 0 — BUILD SUCCESS. Reverting the widening in CallerResolver
alone turns 7 of the new assertions red across all five touched test
classes, so none of them passes vacuously.
2026-10-06 06:20:43 +02:00
Dai Ha 98f3cd5aa7 fleetd #788: document fleet_inbox in the bridge block and the addendum
CI / shell-tests (push) Failing after 8s
CI / contract (push) Successful in 55s
CI / build (push) Failing after 1m57s
Block: observer INBOX right, invariant 3 (inbox is only-as-itself),
invariant 4 (a pane that collects its own mail skips the paste and the
box gate, not the status gate), and an intent-table row. The wiki
template carries the same block; the sync check printed in sync: True.
2026-10-06 05:54:13 +02:00
Dai Ha aec5ff2c2f fleetd #788: merge PR #789 — fleet_inbox, so a pane can collect its own mail
A pane that calls fleet_inbox within 15 s is mod-served: the Injector
offers its next message for collection instead of pasting it, and keeps
it queued until the pane takes it. A pane that stops polling gets its
mail pasted again. The fleet mod polls fleet_inbox every 3 s on one
reused MCP session and hands each message to Claude with
$.prompt.submit.

Lead proof at d9fedfb, in a throwaway worktree:
mvn clean install exit 0; surefire XML 182 files, 2202 tests,
0 failures, 0 errors, 0 skipped. claude plugin validate plugin: passed.
claude plugin test plugin: 15 pass, 0 fail.
2026-10-06 05:53:16 +02:00
Dai Ha d9fedfbc5a fleetd #788: review fixes — cancel vs a collected offer, one MCP session
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 53s
CI / build (pull_request) Failing after 1m59s
Four items from the lead review of PR #789.

1. Injector.cancel touched the inbox offer unconditionally. A pane takes an
   offer on the MCP thread, so between its drain and the next poll the head
   entry is taken but still QUEUED. Cancelling it returned CANCELLED for text
   the pane already held, and cancelling a LATER entry nulled t.inboxOffer and
   so lost the only record that the head was taken, which made the next poll
   offer it a second time. cancel now touches the offer only when the entry is
   the offered head, withdraws first, and answers DELIVERED when the pane took
   it.

2. The mod sent initialize on every fleetTool call and never a DELETE, so
   fleetd's transport kept one session per call -- 20 a minute per pane at a
   3s poll. The mod now holds one MCP session and reopens it only on the 404
   "Session not found" fleetd answers for an id it no longer holds (measured
   against the live daemon, not assumed).

3. MessageService.collectInbox had been inserted between reply's javadoc and
   reply, leaving that block attached to nothing. Moved above it.

4. The timer's catch comment said a throw would kill the timer. It does not;
   the catch keeps every tick from writing an error to the debug log.

mvn clean install: Tests run: 2202, Failures: 0, Errors: 0, Skipped: 0,
BUILD SUCCESS. Counted again over 182 target/surefire-reports/TEST-*.xml:
2202/0/0/0. claude plugin validate plugin and claude plugin test plugin both
exit 0, 15 pass 0 fail.

Three mutation checks, each reverted:
- the exact pre-review cancel body: the two new cancel tests fail with
  "expected: <DELIVERED> but was: <CANCELLED>" and "expected: <true> but was:
  <false>", the other six pass;
- initialize on every call: the two session tests fail (1 vs 2 initializes);
- no 404 retry: only the retry test fails (2 vs 1 initializes).
2026-10-06 05:48:34 +02:00
Dai Ha ac535503ff fleetd #788: fleet_inbox, so a pane can collect its own mail
CI / shell-tests (pull_request) Failing after 9s
CI / contract (pull_request) Successful in 58s
CI / build (pull_request) Failing after 2m15s
A Claude Code session running the fleet mod now pulls its messages from
fleetd and submits them with $.prompt.submit, instead of having them typed
into its terminal by herdr. fleetd names a caller by its pane, so this is
the hop that works between two Claude accounts on one host.

New MCP tool fleet_inbox. It takes no arguments: the pane is the caller's
connection-resolved terminal, so no caller can read another pane's mail.
Authz gets an INBOX action, grouped with REPLY and ASK as only-as-itself.

A pane becomes mod-served by calling fleet_inbox, for a 15s window that
each call renews. The Injector asks that question at the moment it is
about to deliver, and offers the message for collection rather than typing
it. The message stays at the head of the Injector's queue until the pane
takes it, so:

  - delivery is recorded when the pane really has the text, not when it
    was offered, and fleet_poll reports the same states as for a typed
    message;
  - a pane that stops polling leaves the window and its mail is typed
    instead, with nothing stranded and nothing delivered twice. The offer
    is withdrawn under the same monitor that drains it, so an entry is
    removed exactly once;
  - a collected message gets no Enter nudge. Nothing was typed, and an
    Enter in a lead's pane would submit whatever its operator was writing.

cancel, drop and both grace expiries withdraw the offer too, so a message
the caller was told never arrived can never arrive later.

Mod side: a 3s timer collects through the existing fleetTool helper and
submits each message. A fleetd that is down returns from the timer rather
than throwing out of it, which would stop every later poll.

Build: mvn clean install in fleetd/ — Tests run: 2200, Failures: 0,
Errors: 0, Skipped: 0; BUILD SUCCESS. claude plugin validate plugin and
claude plugin test plugin both pass (13 pass, 0 fail).

Two mutations confirm the new tests bind: forcing isModServed false fails
9 of 15, and removing the stop-polling fallback fails exactly
aPaneThatStopsCollectingHasItsMailTypedInstead.
2026-10-06 05:31:08 +02:00
Dai Ha 654b3b5e14 fleetd #788: fleet mod with presence, mail and a fleetd identity probe
Turns the fleet plugin into a Claude Code mod (hooks/hooks.json +
register.js). /fleet-peers and /fleet-mail carry messages through
$.store; /fleet-whoami calls fleet_whoami on the local fleetd over
$.http.fetch.

Measured 2026-10-06 on Claude Code 2.1.290: $.store lives at
<CLAUDE_CONFIG_DIR>/plugins/store/, so the store mailbox does not cross
the ltms and work accounts (two inodes, different rows). /fleet-whoami
reached fleetd from both config dirs, so fleetd is the cross-account hop.
The fleetd pull path that finishes the adapter is #788.

claude plugin validate plugin: passed. claude plugin test plugin:
10 pass, 0 fail.
2026-10-06 05:09:04 +02:00
Dai Ha 1fae8b81a0 fleetd #782: tell a placeholder hint apart from real typed text in the prompt box
CI / shell-tests (push) Failing after 8s
CI / contract (push) Successful in 55s
CI / build (push) Failing after 2m9s
The box gate held every delivery into an idle lead pane. Claude Code
draws a placeholder hint in the empty input box - the pane's own last
submitted prompt - and draws it faint. PromptBox read the pane with
source 'detection', which carries no escape codes at all, so the
dimness was gone before classify ran and the hint read as a draft.

- AgentControl gains readWithStyling, which sends strip_ansi: false.
  The two-argument read is untouched for its other callers.
- PROBE_SOURCE moves to 'visible', the source that does carry styling.
- markerLength skips leading SGR codes, because the grey drawn on an
  empty box's caret sits before the marker glyph.
- boxContent decodes the line into (char, faint) pairs and drops every
  character inside a faint span.
- boxContent also uses Character.isSpaceChar, not only isWhitespace.
  This was cosmetic on 'detection', which trimmed a trailing U+00A0;
  on 'visible' an empty box arrives as '<caret>\u00a0' and would
  otherwise read as DRAFT with 1 character.

Verified in a throwaway worktree at 4ef8156: mvn clean install, BUILD
SUCCESS, 179 test classes, 2184 tests, 0 failures, 0 errors, 0 skipped,
counted from fleetd/target/surefire-reports/*.xml. PromptBoxTest is 21
tests, up from 15.

Measurements behind the four fixtures, taken from seven live panes with
'herdr agent read <paneId> --source visible --ansi': the two held panes
carried SGR 2 around their hint text, the five empty boxes carried no
faint span, and the caret grey on one empty box was a 24-bit colour
sitting before the marker.

Two checks on the move to 'visible' that the fix depends on:

- The active-turn marker 'esc to interrupt' still arrives as one
  contiguous string with styling kept, so the UNREADABLE guard for a
  live turn is intact.
- Across all seven panes the only intensity codes emitted are 2 and 0.
  SGR 22 (normal intensity) never appears, and no compound code mixes
  an intensity with a colour, so exact-matching those two codes is
  enough for this build. A build that emitted 22 would leave faint on
  and could read a drafted box as empty; tracked on #782.

PR #784's HOLD_GIVE_UP_STREAK is deliberately not here. I asked for it
and it was wrong: the hold cadence measures 15.07s, so 1200 holds is
about five hours, and giving up submits whatever sits in the box.
2026-10-06 04:28:02 +02:00
Dai Ha 4ef815682b fleetd #782: tell a placeholder hint apart from real typed text in the prompt box
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 1m1s
CI / build (pull_request) Failing after 2m9s
PromptBox probed the detection region, which carries no ANSI styling, so a dim
placeholder hint (the pane's last submitted prompt) read as the operator's draft
and held the delivery forever.

Move the probe to the visible region, read with strip_ansi:false
(AgentControl.readWithStyling), skip leading SGR escapes before matching the
box marker, use Character.isSpaceChar so a non-breaking space in an empty box
counts as padding, and exclude any character drawn inside a faint (SGR 2) span
from the box content. An all-faint box now classifies as EMPTY instead of
UNREADABLE.
2026-10-06 04:22:59 +02:00
Dai Ha 6596458ce6 fleetd #780: queued sends no longer lie to fleet_poll or fleet_send
CI / shell-tests (push) Failing after 9s
CI / contract (push) Successful in 55s
CI / build (push) Failing after 2m2s
Three false receipts on the delivery path to a pane are fixed:

- fleet_poll reported "pending — worker working" for a message still
  sitting in the injector queue. MessageService.pendingDetail now reads
  Injector.queuedWaitMillis and says "queued, not yet delivered".
- fleet_send's accept text carried no warning when the target was not
  injectable. FleetMcp.sendAsync now appends one.
- a queued message had no timeout at all. Injector gains
  QUEUE_WAIT_GRACE_POLLS (4800, ~20 min at the 250ms poll), mirroring
  READINESS_GRACE_POLLS, and fails the queue through onTurnFailed.

Verified in a throwaway worktree at 1cc0322: mvn clean install, BUILD
SUCCESS, 179 test classes, 2179 tests, 0 failures, 0 errors, 0 skipped,
counted from fleetd/target/surefire-reports/*.xml. All four new tests
are present in the XML and passed.

Reviewed by two reviewers. Two findings, both confirmed in the code and
both left open rather than fixed here:

- FleetMcp.notInjectableWarning reads only AgentStatus.injectable(), not
  the Fleetd.deliverableTo presence gate the Injector actually uses, so
  an idle pane that has not connected its bridge MCP still gets a clean
  accept receipt. This is the headline false receipt of #780 and is the
  reason #780 stays open.
- the queueStalled path does not call forget.accept. The proposed fix is
  rejected: a target stuck UNKNOWN may be alive but unclassifiable, and
  forgetting its presence would make it permanently undeliverable, since
  presence is only re-established by an MCP request an idle pane has no
  reason to make. Presence is already cleared on session release
  (SessionManager.java:477).
2026-10-06 04:03:43 +02:00
Dai Ha 1cc0322782 fleetd #780: tell the truth about a queued-but-undelivered send
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 52s
CI / build (pull_request) Failing after 2m10s
A send to a pane that never frees up was accepted, then polled as
"pending — worker working" forever, with no WARN and no timeout. That
text is only ever correct for a message the injector already handed
off; a message still sitting in its queue now reports as queued, with
the target's live status and how long it has been waiting.

Injector.enqueue() now stamps Pending.enqueuedAtMillis so
queuedWaitMillis(target) can tell "never attempted" apart from
"delivered, now being worked on". MessageService.pendingDetail() uses
it to pick the poll wording. FleetMcp.sendAsync() adds a best-effort
warning to the accept text when the target is not injectable at send
time, per the brief's stated preference for a warning over a hard
refusal (a short busy spell is normal).

Injector gets a new bound, QUEUE_WAIT_GRACE_POLLS (4800 polls, ~20min
at the 250ms poll interval), mirroring READINESS_GRACE_POLLS: a
message that sits at the head of the queue with no delivery attempt
for that long fails via onTurnFailed, the same path a readiness
timeout uses, without touching presence — the target is busy, not
gone.

Fixed InjectorTest.readinessGraceExpiryLogsTheMeasuredElapsedTimeNotArithmeticOnConstants's
stub clock, which now sees one extra, legitimate nowMillis read from
enqueue()'s new stamp.

Tests: aQueuedButNeverInjectedMessageDoesNotPollAsWorking +
aDeliveredMessageStillPollsAsWorkerWorking (poll wording, with
positive control); failsAQueuedMessageWhoseTargetNeverFreesUp +
aTargetThatFreesUpBeforeTheQueueWaitGraceIsDeliveredNormally (timeout,
with positive control).
2026-10-05 19:54:37 +02:00
Dai Ha c043d149cf fleetd #775: name the trigger flag for what it now reads
CI / shell-tests (push) Failing after 8s
CI / contract (push) Successful in 1m0s
CI / build (push) Failing after 1m57s
The flag tested leader.tab() and was named for it. It now tests whether
fleet.leaders has any entry at all, so the old name states a condition the
code no longer checks.
2026-10-05 14:21:19 +02:00
Dai Ha fc786d0f67 fleetd #775: say which remedy applies to a lead vs a collaborator
The ticket's correction comment pointed out the refusal message still
implied removing a lead's tab helps, when only placement: tab does.
Restate the message so the lead and collaborator remedies are not
conflated, and keep the variable name the correction specified.
2026-10-05 14:19:17 +02:00
Dai Ha b314cb4d51 fleetd #775: pane-placement guard trigger keys on lead existence, not tab:
The guard's lead half now fires whenever fleet.leaders has any entry,
since a lead's tab is always labelled by the fixed LEAD_TAB_LABEL
constant regardless of its own deprecated tab: field. The refusal
message no longer advises removing a lead's tab:, which cannot
satisfy the guard any more.
2026-10-05 14:19:17 +02:00
Dai Ha a3d296f639 fleetd #770: the lead identity check is the label AND the space
CI / shell-tests (push) Failing after 10s
CI / contract (push) Successful in 49s
CI / build (push) Failing after 2m11s
The redeploy skill's check 4 and the script's closing hint both told the
operator that a lead is found by fleet.leaders.*.tab. Identity is now the
fixed 'lead' label together with the lead's configured workspace, so both
would have sent a reader to a key that no longer decides anything.
2026-10-05 14:01:54 +02:00
Dai Ha 79423787d0 fleetd #770: lead identity keys on the space, not the tab label
CI / shell-tests (pull_request) Failing after 9s
CI / contract (pull_request) Successful in 54s
CI / build (pull_request) Failing after 1m57s
CI / shell-tests (push) Failing after 9s
CI / contract (push) Successful in 53s
CI / build (push) Failing after 1m52s
The lead tab label becomes a fixed constant (Leader.LEAD_TAB_LABEL = "lead");
fleet.leaders.<name>.tab is now optional legacy, matched case-insensitively
alongside the constant via Leader.acceptedLabels(). The uniqueness boundary
between leads moves from the exact tab text to the workspace: FleetConfig
refuses two leaders that share a workspace, LeadTabScanner indexes lead
labels per space (collaborators stay space-agnostic), and
LeadLauncher.leadNameOf/countLeads require both the accepted label and the
lead's own space to match, so a legacy-labelled tab in the wrong space never
counts and a daemon restart never double-spawns a second lead next to a live
one. Config validation also refuses a fleet.tabLabel template or a
collaborator tab that can render as the fixed lead label.
2026-10-05 13:49:14 +02:00
Dai Ha 364b229db9 fleetd #771 step 1: report each pane's workspace label in fleet_list
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 53s
CI / build (pull_request) Failing after 1m57s
CI / shell-tests (push) Failing after 6s
CI / contract (push) Successful in 54s
CI / build (push) Failing after 1m50s
Adds workspaceLabel next to workspaceId on fleet_list's panes row, read
from herdr's workspace.list via a new PaneLocator.workspaceLabelsByWorkspaceId().
An observer's reduced row still excludes it; a herdr failure or an unknown
workspaceId yields workspaceLabel: null without costing the rest of fleet_list.
2026-10-05 11:37:41 +02:00
Dai Ha ac436efefb fleetd #761: invariant 4 — a lead's own pane needs an empty input box
CI / shell-tests (push) Failing after 11s
CI / contract (push) Successful in 52s
CI / build (push) Failing after 1m58s
2026-10-05 11:17:33 +02:00
Dai Ha 7dad054045 Merge remote-tracking branch 'origin/worker/761-draft-aware-lead-nudge-d05850-4' into vfy/761
CI / shell-tests (push) Failing after 9s
CI / contract (push) Successful in 1m0s
CI / build (push) Failing after 2m16s
2026-10-05 11:09:40 +02:00
Dai Ha 4e414c475f 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
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).
2026-10-05 11:07:40 +02:00
Dai Ha 9084667493 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
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).
2026-10-05 10:49:50 +02:00
Dai Ha 2289e94223 fleetd #759: fix the fleet_reply comment and the hand-copied role list
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 44s
CI / build (pull_request) Failing after 1m59s
CI / shell-tests (push) Failing after 10s
CI / contract (push) Successful in 55s
CI / build (push) Failing after 1m59s
Finding 4: the comment above fleet_reply's handler claimed the authz check
asks whether the caller is a worker at all. It actually checks terminal
ownership (Authz.java REPLY/ASK -> caller.ownsSession), which is why an
observer can reply on its own pane with no role test involved.

Finding 5: fleet_list's tool description hardcoded 'architect/dev/reviewer',
missing hunter. Added MemberRole.wireNames() (pulled out of parse()'s error
message builder, which now calls it too) and used it in the description so
the list can't drift again.
2026-10-05 10:44:55 +02:00
Dai Ha 92adfcfae5 fleetd #756/#758: the canonical block no longer says panes are unlistable
CI / shell-tests (push) Failing after 6s
CI / contract (push) Successful in 57s
CI / build (push) Failing after 2m13s
The table row for an unconfigured pane told every session "Neither
fleet_list nor ListAgents lists these". PR #762 made that false for
fleet_list, and a stale note of this shape is the worst kind: it tells a
future session it cannot do the thing at the moment doing it is the job.

Two edits:

- The observer definition now says where an observer finds a target id,
  which is the one thing it could not learn before.
- The table row names the panes array, says the row is full for a lead, an
  architect or a collaborator and filtered for an observer, and keeps the
  herdr tab list join as the fallback. ListAgents still lists none of them.

wiki/7-Use-Cases.md regenerated from this block in the wiki submodule at
4872227; the sync check prints in sync: True.
2026-10-05 10:39:32 +02:00
Dai Ha 5f7f388e69 Merge remote-tracking branch 'origin/worker/756-758-observer-pane-discovery-7e6ffd-1' into vfy/762
CI / shell-tests (push) Failing after 7s
CI / contract (push) Successful in 54s
CI / build (push) Failing after 1m56s
2026-10-05 10:29:49 +02:00
Dai Ha 39accf73e6 fleetd #759: fix the third copy of the role claim, and drop two references that rot
CI / shell-tests (push) Failing after 8s
CI / contract (push) Successful in 49s
CI / build (push) Failing after 1m58s
ConnectionIdentity's Caller record carried the same wrong rule as the two
places PR #760 fixed: it called the terminal a worker's, and read a null
terminal as the primary. The brief for #760 named only two of the three spots.

MemberPresence pointed at FleetMcp.markTrackedCallerPresent by name inside
{@code}, which the compiler does not check, and inject has no dependency on
mcp so a {@link} would add a cross-package reference. States the principle
instead, which stays true whichever roles qualify. Drops a ticket key.
2026-10-05 10:26:36 +02:00
Dai Ha 5c2f296bc3 fleetd #756/#758: panes array reads the architect-slot gate and widens to observer
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 52s
CI / build (pull_request) Failing after 2m9s
paneRole now reads CallerResolver#boundToArchitectSlot (made public, no
second definition) so a slot-bound pane with no live member reports
"architect", matching what sendableObserverTarget already allowed as a
SEND target.

panesVisibleTo now admits an observer, since an observer holds SEND to
another observer pane. Its panes rows are filtered to
sendableObserverTarget and reduced to sessionId/label/status/role/
deliverable; every other caller's rows are unchanged.
2026-10-05 10:24:00 +02:00
Dai Ha 0f2ec7a6b5 fleetd #759: fix three role-model comments that describe the pre-CB-501 rule
CI / shell-tests (pull_request) Failing after 6s
CI / contract (pull_request) Successful in 54s
CI / build (pull_request) Failing after 2m7s
Role.PRIMARY, ConnectionIdentity (class javadoc + callerTerminal), and
MemberPresence's class javadoc each state a role model the code no longer
implements. Comment-only change.
2026-10-05 10:18:01 +02:00
Dai Ha 02eff4c532 fleetd #743: an observer may now send to another observer
CI / shell-tests (push) Failing after 13s
CI / contract (push) Successful in 51s
CI / build (push) Failing after 2m4s
Three claims in the canonical block went false when the observer SEND grant
merged, and this file is the instruction surface the bridge ships:

  - the observer role definition said "never SEND"
  - invariant 3 listed send as "lead, architect, or collaborator"
  - the hand-opened-pane row said such a pane "cannot fleet_send back"

The grant is narrow. Authz permits an observer's SEND only when
CallerResolver.sendableObserverTarget() classifies the target as an observer
too, so a lead, a collaborator, an architect slot and a spawned member are
each refused. TASK_READ stays denied, so #705 remains closed.

Measured on the merged tree: mvn clean install exit 0, 2137 tests from 178
surefire files, 0 failures. Mutating away the grant's call site
(FleetMcp.java:474) kills exactly FleetMcpObserverSendDeliveryTest, so the
behaviour is pinned and not only the predicate.
2026-10-05 09:42:43 +02:00
Dai Ha 92b6d9406b Merge remote-tracking branch 'origin/worker/743-observer-send-4706db-6' 2026-10-05 09:37:14 +02:00
Dai Ha c4498607e1 Merge remote-tracking branch 'origin/worker/743-pane-discovery-ad5b75-5' 2026-10-05 09:37:14 +02:00
Dai Ha e42eab5b4c fleetd #743: pin GET /agents' tab-label merge with a positive test
CI / shell-tests (pull_request) Failing after 9s
CI / contract (pull_request) Successful in 49s
CI / build (pull_request) Failing after 2m5s
2026-10-05 09:34:15 +02:00
Dai Ha b1d2cb48ac fleetd #743: make the pane-label scan best-effort, trim justification comments
CI / shell-tests (pull_request) Failing after 10s
CI / contract (pull_request) Successful in 51s
CI / build (pull_request) Failing after 1m56s
A workspace.list/tab.list failure in the label scan no longer costs the
caller the agent roster (GET /agents) or the leads/members/capacity/
coordinator rows (fleet_list) that never needed it. Both call sites now
fall back to an empty label map on HerdrException, so a pane row still
renders with label:null instead of the whole response failing.

Also cuts four comments down to the current contract, per the project's
comment rule: dropped the reviewer-facing justification from Fleetd's
deliverableTo javadoc, panesVisibleTo's javadoc, the fleet_list handler's
inline comment, and the panesVisible assembly-gate comment, and removed
the two fragments describing what a test must do.
2026-10-05 09:19:16 +02:00
Dai Ha 001367d82c fleetd #743: drop the redundant source-scrape test for attributeIfObserver
CI / shell-tests (pull_request) Failing after 6s
CI / contract (pull_request) Successful in 57s
CI / build (pull_request) Failing after 2m4s
FleetMcpObserverSendDeliveryTest already kills the same mutation end to
end (it asserts the exact attributed text herdr receives), and the code
quality rule in CLAUDE.md caps new source-text tests in this file at the
existing count.
2026-10-05 09:16:29 +02:00
Dai Ha 9fdcaaa8fd fleetd #743: let one observer pane message another, and nothing else
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 56s
CI / build (pull_request) Failing after 2m0s
Authz.SEND now grants an observer a narrow path: it may reach only a
target that CallerResolver.sendableObserverTarget() would itself
resolve as OBSERVER, never a lead, a collaborator, or a live spawned
member's terminal. This mirrors the existing collaborator SEND clause
rather than adding an unconditional caller.isObserver() grant, which
fleetd #705 already rejected as too broad.

Because the receiving pane cannot otherwise tell an observer's SEND
apart from a human paste, FleetMcp.attributeIfObserver prefixes the
delivered text with the sender's own daemon-resolved terminal on both
the MCP and REST entry paths, for exactly this one new path.

FleetAppAuthTest's start() helper never wired a spawnedMemberRole, so
its "worker" fixture actually resolved as OBSERVER under the real
CallerResolver -- invisible before because OBSERVER and WORKER shared
the same (zero) SEND grant. Granting OBSERVER a real SEND surfaced it:
two SEND-denial tests started passing for the wrong caller. Fixed the
fixture to resolve term_a as a live DEV, matching the helper's own
documented contract.
2026-10-05 09:03:16 +02:00
Dai Ha 459a523e2c fleetd #743: expose herdr pane discovery (tab labels) over REST and MCP
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 1m0s
CI / build (pull_request) Failing after 2m15s
GET /agents now carries each agent's tab label, merged in from the same
herdr daemon(s) the roster is drawn from. fleet_list gains a panes array
with the same label, the sessionId fleet_send takes as a target, the
role the daemon resolves that pane as, and the deliverable gate the
injector itself enforces (Fleetd#deliverableTo, now public). Gated like
leads/collaborators (primary, architect, collaborator), not bare READ,
since a tab label and a member's cwd are not roster facts every READ
caller may see.
2026-10-05 08:58:58 +02:00
Dai Ha 291dc02c77 fleetd #743: document that a hand-opened pane is reachable with no config
The lead's intent->tool table had no row for messaging an unconfigured pane,
and the user-scope instruction file said outright that the fleet has no route
to an interactive session unless an operator registers it as a collaborator.
That claim is false and it is load-bearing: a session reading it concludes the
exchange is impossible and stops, which is what happened here.

Delivery is gated on presence, not on SEND. contextExtractor runs on every MCP
request including initialize, markTrackedCallerPresent enrols an observer into
MemberPresence, and deliverableTo tests presence before the lead and
collaborator maps. So connecting the server is the enrolment, and fleet_reply
is gated on owning your own pane, which every pane does.

Add the table row, and add the enrolment side of the deliverability gate to the
flows page next to the existing "a spawned member is not deliverable until it
has mounted the MCP" bullet, which is the same gate read the other way.

Measured on a real pane, not a fake: trinotes answered with no fleet config, no
restart, and its fleet_* tools still deferred and unloaded.
2026-10-05 08:56:01 +02:00
Dai Ha be835aa259 Merge branch 'worker/749-edge-baseline-28d1a0-3'
CI / shell-tests (push) Failing after 12s
CI / contract (push) Successful in 52s
CI / build (push) Failing after 2m4s
2026-10-05 07:47:18 +02:00
Dai Ha 80506c79d0 fleetd #748: drop a cross-reference the comment cleanup left dangling
errorPatternCoverageLine pointed at exhaustedPatternCoverageLine's javadoc
"for the measured swap mutation this pairing guards against". That narrative
was removed from the destination, so the pointer led nowhere.

The pairing with BUILT_IN_DEFAULT is the contract and it stays. What the
mutation proved belongs in history, not in the comment.
2026-10-05 07:46:59 +02:00
Dai Ha 6677ec8c63 fleetd #749: pin PackageCyclesTest's exceptions to exact edges, not whole packages
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 1m8s
CI / build (pull_request) Failing after 2m25s
ignoreCycle() used to exempt every dependency between two packages, in both
directions, for the whole package. That meant a brand new dependency added
later between an already-excepted pair (auth/mcp, mcp/msg, inject/msg,
metrics/msg, msg/session) was silently exempted too, exactly where the
msg package makes the gate matter most.

Replace the package-wide ignore with a frozen baseline of the 45 exact
origin-class -> target-class edges that exist today between those five
pairs, and ignore only those via SliceRule.ignoreDependency(String, String).
A new dependency between a baselined pair is not in that set, so it is no
longer ignored and the existing beFreeOfCycles() check (or, when the new
edge alone would not form a cycle, a dedicated set-equality check) fails
and names the exact origin class, target class and package pair.

The set-equality check also fails on a baseline entry whose dependency no
longer exists in the code, so a removed edge cannot rot in the baseline
and mask the pair's eligibility for the ticket #131 removal steps. Rewrote
the javadoc to describe only the current contract.
2026-10-05 07:45:51 +02:00
Dai Ha ca0c965932 Merge branch 'worker/748-dead-comment-refs-f42ac5-4' 2026-10-05 07:44:44 +02:00
Dai Ha 2adb950a12 fleetd #748: five code-quality rules enter the repo
Two architects reviewed the codebase independently on separate backends and
both returned MIXED, not "a mess": 0 public mutable fields, 12 extends of
which 8 are exceptions, 120 records, and 2 files over 1000 code lines rather
than the 9 a total-line count suggests. Both rejected a Clean Code section,
a SOLID list, a pattern catalogue, class and method length limits, and a
coverage gate as text that would change no behaviour.

The comment rule they were briefed against was never in this repo. It lives
in the operator's personal global config, so it was not in git, never
reviewed, and not versioned with the code. Its "goes in the ADR" line was
unfollowable because this project has no ADR, which sent load-bearing
knowledge to a destination that does not exist. Rule 2 redirects it to
docs/<subject>.md, which exists.

Rule 4 covers what neither architect ranked first and both described: a
testing problem relieved by reshaping production code. 54 static factories
on Fleetd, 7 volatile race hooks in MessageService, 8 tests asserting on
main source as text, and a 452-line composition root across 8 tickets.

Rule 4's judgement half is marked as having no mechanism. A cap stops a
count growing; no test distinguishes a good decomposition from a bad one.

Verified: canonical block still byte-identical with wiki/7-Use-Cases.md.
2026-10-05 07:36:20 +02:00
63 changed files with 4728 additions and 496 deletions
+2 -2
View File
@@ -8,8 +8,8 @@
{
"name": "fleet",
"source": "./plugin",
"description": "Mount the fleetd MCP gateway and apply standard Claude Code settings so a session can orchestrate delegated workers. Ships no credentials.",
"version": "0.2.0",
"description": "Apply standard Claude Code settings so a session can orchestrate delegated workers, and run the fleet mod for cross-session messaging. Mounting the fleetd MCP gateway is the instance's or the project's job, not this plugin's. Ships no credentials.",
"version": "0.3.0",
"author": {
"name": "LTMS"
}
+6 -3
View File
@@ -59,9 +59,12 @@ if the script is unavailable or a step fails, this is what it was protecting you
3. **A restart is the only way deferred config keys take effect.** That is usually the reason to do
it. The startup log names which keys it accepted and which it deferred — read those lines rather
than assuming.
4. **Re-check identity afterwards.** Call `fleet_whoami` and confirm it still answers `primary`. The
lead is found by its tab label (`fleet.leaders.*.tab`), and a lead whose tab no longer matches is
demoted to worker, which refuses every orchestration call.
4. **Re-check identity afterwards.** Call `fleet_whoami` and confirm it still answers `primary`. A
lead is found by two things together: its tab is labelled `lead`, and that tab sits in the space
named by `fleet.leaders.<name>.workspace`. Both must match, so a renamed tab *and* a space whose
label differs from the config each demote the lead to worker, which refuses every orchestration
call. A `tab:` still in config is accepted as a second label for that lead, and the daemon logs
one deprecation warning naming it at startup.
5. **Prove the new jar is the one running.** Confirm a *fresh* `fleetd listening` line at the end of
`fleetd/fleetd.out`, dated after the restart. An old daemon that never died looks identical from
the outside.
+71 -8
View File
@@ -33,8 +33,12 @@ gate uses. A worker also carries its `sessionId`, `profile`, `worktree` and `bra
carries the slot name it was bound to; a collaborator carries its registry name and its own
`sessionId`, and **no `leader` key** — a collaborator is a named peer, not a primary. An **observer**
carries only its own `sessionId`: a pane the daemon could not place as any of the above, authorized
to `READ`/`METRICS` and to `REPLY`/`ASK` on its own pane and nothing more — never `SEND`, never a
ticket. Don't infer what you can ask.
to `READ`/`METRICS`, to `REPLY`/`ASK`/`INBOX` on its own pane, and to `SEND` only to a lead or to a target that
resolves as an observer too — never to a collaborator, an architect, or a spawned member, and never a
ticket. It finds such a target in `fleet_list`'s `panes` array, which for an observer is filtered to
exactly what it may send to and reduced to `sessionId`, `label`, `status`, `role` and `deliverable`;
a lead's row reads `role: "lead"`.
Don't infer what you can ask.
Only if that call is unavailable, fall back to these — each is one-way, so keep reading until one
fires: the reply charter in your system prompt (*"You are a spawned member in the
@@ -62,14 +66,32 @@ and the sender silently receives nothing. Fail toward the recoverable error.
2. **The bridge is the only channel.** Text you print in your terminal reaches nobody — the other
side cannot see your screen. An answer that isn't in a `fleet_*` call is silently discarded.
3. **Identity comes from the connection, never an argument.** Workers never pass a target; you
cannot act as another session. Spawn/stop/drain are lead-only; **send is lead, architect, or
collaborator** — and a collaborator may send only to a lead or another collaborator, never to a
spawned member's terminal; reply/ask are only-as-itself — any peer may answer for its own pane,
and for no other. A call outside your role is refused, not queued.
cannot act as another session. Spawn/stop/drain are lead-only; **send is lead, architect,
collaborator, or observer** — and a collaborator may send only to a lead or another collaborator,
never to a spawned member's terminal, while an observer may send only to a lead or another observer
pane;
reply/ask/inbox are only-as-itself — any peer may answer, or collect its mail, for its own
pane, and for no other. A call outside your role is refused, not queued.
4. **Delivery is status-gated: one message per turn.** Don't busy-poll a peer's terminal and don't
re-send because a call looks slow — the bridge delivers when the peer is `idle`, `blocked` or
`done`. A spawned member must **also** have mounted the bridge MCP: until it has, it is not
deliverable, and a send waits on that gate for ~60s and then fails without ever reaching its pane.
**A lead's own pane has a second gate: its input box must be empty.** The multiplexer pastes and
submits in one step, so a delivery that lands while the operator is typing submits their
half-written line. A heartbeat, a ticket nudge and lead-to-lead mail therefore wait until the box
is clear, and a pane the daemon cannot read as a box waits too. A direct `fleet_send` to a lead's
pane does **not** wait for the box yet (fleetd #793); a lead that runs the fleet mod collects
that mail instead of having it pasted, so it is not exposed. Nothing is lost — every one of
those paths retries — but a lead that leaves text sitting in its box receives nothing until it
clears, and the only sign is one warning in `fleetd.out` after 20 held checks in a row. Delivery
to a *member* is not gated this way, because nobody types in a member's pane.
**A pane that collects its own mail skips the paste.** A session running the fleet mod calls
`fleet_inbox` on a timer. While its last call is under 15s old, the daemon queues that pane's
next message for collection instead of pasting it, and the mod hands it to Claude with
`$.prompt.submit` — so the box gate does not apply, but the status gate still does. When the calls
stop, the pane's mail is pasted again, and nothing is lost. Pasted or collected, the fleet
channel also crosses Claude accounts on one host, because the daemon names a caller by its pane;
`SendMessage`, `ListAgents` and the mod's own store each stay inside one account.
5. **Never move a fleet session, pane or peer except through the bridge.** The bridge owns policy;
the multiplexer owns PTYs. Any route that changes fleet state without the bridge's checks
bypasses every rule above — the `herdr` CLI and its socket are the usual example.
@@ -178,9 +200,11 @@ you decide.
| Message a **peer lead** on this host | `fleet_send{sessionId: <their terminal>, content}` — `fleet_list` → `leads` reports it. Coordination only, **never** a task |
| Message a **peer lead** on another daemon or host | `fleet_send{coordId: <their coord-id>, content}` — needs a `coordinator:` block; your own coord-id is in `fleet_list`. Coordination only, **never** a task |
| Message a **collaborator** on this host | `fleet_send{sessionId: <their terminal>, content}` — `fleet_list` reports a `collaborators` array, and each row carries that peer's `name` and the `sessionId` you send to. It is visible to you, to an architect and to another collaborator, never to a worker. Coordination only, **never** a task |
| Message an **unconfigured pane** — a tab a person opened by hand | `fleet_send{sessionId: <their terminal>, content}` — it needs **no** `fleet.collaborators` entry and no restart, because a pane becomes deliverable the moment its agent connects the bridge MCP. `fleet_list`'s `panes` array reports every such pane with its label and the terminal id to send to — the full row for you, an architect or a collaborator; filtered and reduced for an observer. **`ListAgents` still never lists these**, and joining `herdr tab list` to `GET /agents` on `tab_id` stays the read-only fallback if the array is missing. Such a pane resolves as an `observer`: it can answer you with `fleet_reply`, and it can `fleet_send` to you or to another observer pane, but never to a collaborator or a member. Coordination only, **never** a task |
| Answer a peer lead that messaged you | `fleet_send{coordId}` — or `{sessionId}` if they are on this host. **Not** `fleet_reply`: it has no peer route and the publish is refused |
| Read your own held lead-to-lead mail (no ack) | `fleet_poll{coordId: <your own coord-id, from fleet_list's coordinator.selfId>}` — primary-only; never acks, so `fleet_list`'s `held[]` still shows it after. `fleet_list`'s `held[]` gives only a truncated preview — this is the only way to read the full body |
| Collect a held reply | `fleet_poll{target}` · then `fleet_ack{target, msgId}` |
| Collect the mail queued for your OWN pane, instead of having it pasted | `fleet_inbox` — no arguments, any role, own pane only. The fleet mod calls it on a timer; you rarely call it by hand |
| Tear down a member | `fleet_stop{paneId}` |
| Replace your OWN lead session when its context is full | `fleet_handover{action:"open", reason?}` → write the handover file it names → `fleet_handover{action:"confirm", token, operatorConfirmed}`. Primary-only. **In that order**: the file must be modified *after* `open`, or `confirm` refuses it as stale. There is no terminal parameter — the pane is always your own, so you can never roll another lead. `{action:"cancel", token}` drops a pending request |
@@ -333,8 +357,17 @@ must obey belongs in the charter, not here.
`handover` (write the file a fresh lead session inherits when the outgoing one hands off,
fleetd #480).
- **This repo is also a Claude Code marketplace, and ships a plugin.** `.claude-plugin/marketplace.json`
points at `plugin/`, which carries the MCP mount and the `setup` skill
(`/claude-bridge:setup` — make any project bridge-ready). It was added in CB-527 and then went
points at `plugin/`, which carries the `setup` skill (`/fleet:setup` — make any project
bridge-ready) and no MCP mount: the instance or the project mounts `fleet`. `plugin/` is also a Claude Code mod (`plugin/hooks/`):
it polls `fleet_inbox` and hands each message to Claude with `$.prompt.submit`. Measured
2026-10-06: `fleet@fleetd` 0.3.0 is installed at user scope in the `gx10`, `ltms`, `ollama` and
`work` instances, so every session started from them runs the mod, and a spawned member on those
config dirs loads it too — but the mod skips the inbox poll for a `worker` or an `architect`, so a
member still gets its brief pasted. The install made a copy under each instance's
`plugins/cache/fleetd/fleet/0.3.0`, so assume an edit to `plugin/` reaches sessions only after a
version bump and `claude plugin update fleet@fleetd` per instance. Re-measure with
`grep -l '"fleet@fleetd"' ~/.ccs/instances/*/plugins/installed_plugins.json`; delete this sentence
if the plugin is uninstalled. It was added in CB-527 and then went
unmentioned by every instruction file, so it drifted and a later session planned it from scratch
(#362). **Read `plugin/` before designing anything about onboarding a project.** Two limits are
structural, not bugs: a plugin cannot carry the role agent files, because
@@ -502,3 +535,33 @@ to replace them.
Prefer the unnamed lambda parameter `_` for required-but-unused params; a non-public
`static void main(String[])` is valid (JEP 512) and boots via `java -jar`.
## Code quality — five rules, and what each already cost (enforced)
Measured at `7f0c4a8`: 124 main files, 36,278 lines, of which **17,850 are code** — 44% is comment,
and only **two** files exceed 1000 *code* lines. Encapsulation and inheritance are already sound (0
public mutable fields; 12 `extends`, 8 of them exceptions; 120 records). So there is **no Clean Code
section, no SOLID list and no pattern catalogue** here: two architect reviews rejected those
independently as text that would change no behaviour. These five rules are the whole standard.
1. **A comment states the current contract or a current maintainer constraint — nothing else.** No
tickets, history, dates, measurements or review rationale; those go in the commit message or the
MR description. Source code only — this rule never applies to Markdown.
2. **A javadoc block stops at 30 lines.** Longer means it is a design argument, so it moves to
`docs/<subject>.md` and is linked in one line. The longest here is 235 lines
(`config/ConfigRef.java`) and the knowledge in it is load-bearing: **move it, never delete it.**
This project has **no ADR** — subject pages under `docs/` are the destination.
3. **A comment in main source never names a test class.** There are 44 such names in 76 places and
**2 are already dead**, because a name inside `{@code}` is invisible to the compiler and rots in
silence. Say what the code guarantees; the test is found by looking.
4. **Never relieve a testing problem by reshaping production code.** `Fleetd` carries 54 static
factories, `MessageService` carries 7 `volatile` race hooks, and 8 tests assert on main source as
*text*. Make the part injectable instead. `FleetdAssembly.assembleAndStart` is 452 lines and may
not grow; no new source-text test may be added.
5. **No new package cycle, and no widening of a recorded one.** Five pairs are frozen as an exact
edge baseline in `PackageCyclesTest` — four of them involve `msg`.
Rules 1, 2, 3 and 5 have build checks, and Gitea CI runs them on every PR, so they bind members too.
**Rule 4's judgement half has no mechanism**: a cap stops a count growing, but no test tells a good
decomposition from a bad one. That half is a review obligation, and saying so is deliberate — a rule
dressed as a gate it does not have is worse than an honest review item.
+6
View File
@@ -182,6 +182,12 @@ Two consequences a lead feels directly:
is fine; the message simply waits, and then restarts the member when it next goes idle.
- **A spawned member is not deliverable until it has mounted the MCP.** Until then a send waits on
that gate for about 60 seconds and then fails without ever reaching the pane.
- **The same gate is what makes an unconfigured pane deliverable.** `contextExtractor` runs on
every MCP request, `initialize` included, and `markTrackedCallerPresent` enrols a spawned member
*or* an observer into `MemberPresence`; `deliverableTo` then tests presence before the lead and
collaborator maps. So mounting the server is the enrolment, and a tab a person opened by hand can
be sent to with no config and no restart. It answers with `fleet_reply` — it cannot `fleet_send`,
because `Authz` keeps `SEND` to a primary, an architect or a collaborator.
`UNKNOWN` is deliberately neither injectable nor a pickup. A pane whose status cannot be read is
not a pane that is safe to write to — see fleetd #176 for what happens when a gate treats an
@@ -234,7 +234,7 @@ public final class Fleetd {
* <p>Both sets are read through their supplier on each call rather than snapshotted, so a lead or
* collaborator discovered by {@code leadScan} after startup becomes deliverable without a restart.
*/
static Predicate<String> deliverableTo(MemberPresence presence, Supplier<Map<String, String>> leads,
public static Predicate<String> deliverableTo(MemberPresence presence, Supplier<Map<String, String>> leads,
Supplier<Map<String, String>> collaborators) {
return target -> presence.isPresent(target) || leads.get().containsKey(target)
|| collaborators.get().containsKey(target);
@@ -381,13 +381,11 @@ public final class Fleetd {
}
/**
* fleetd #415 (review follow-up): the {@code errorPattern} counterpart of {@link
* #exhaustedPatternCoverageLine}, paired explicitly with {@link
* CompletionResolver.UnsetMeaning#BUILT_IN_DEFAULT} — an unset {@code errorPattern} still runs
* backend-error classification against {@code CompletionResolver}'s built-in {@code
* BACKEND_ERROR} pattern, so the empty case is not "off". See {@link
* #exhaustedPatternCoverageLine}'s javadoc for the measured swap mutation this pairing guards
* against.
* The {@code errorPattern} counterpart of {@link #exhaustedPatternCoverageLine}, paired
* explicitly with {@link CompletionResolver.UnsetMeaning#BUILT_IN_DEFAULT} — an unset
* {@code errorPattern} still runs backend-error classification against
* {@code CompletionResolver}'s built-in {@code BACKEND_ERROR} pattern, so the empty case is
* not "off".
*/
static String errorPatternCoverageLine(Set<String> allProfiles, Set<String> configuredProfiles) {
return CompletionResolver.coverage("errorPattern", CompletionResolver.UnsetMeaning.BUILT_IN_DEFAULT,
@@ -254,18 +254,28 @@ final class FleetdAssembly {
if (leadTerminals.size() > 1) {
log.info("leads: {} panes recognised {}", leadTerminals.size(), leadTerminals.values());
}
// CB-531/CB-579: discover leads by the tab labels the operator writes, one scanner per
// configured lead's own exact `tab:` label. fleetd #669: the same scan also recognises a
// configured collaborator's tab, so one herdr pass answers both.
// Discover leads by their tab labels, one scanner per configured lead's own space. fleetd
// #669: the same scan also recognises a configured collaborator's tab, so one herdr pass
// answers both.
final Supplier<Map<String, String>> leads;
final Supplier<Map<String, String>> collaboratorTerminals;
var leaders = cfg.fleet().leaders();
var collaboratorsConfig = cfg.fleet().collaborators();
if (!leaders.isEmpty() || !collaboratorsConfig.isEmpty()) {
Map<String, String> tabToName = new LinkedHashMap<>();
Map<String, Map<String, String>> leadLabelsBySpace = new LinkedHashMap<>();
Map<String, String> spaceByLeadName = new LinkedHashMap<>();
leaders.forEach((name, leader) -> {
if (leader != null && leader.tab() != null && !leader.tab().isBlank()) {
tabToName.put(leader.tab(), name);
if (leader == null) {
return;
}
spaceByLeadName.put(name, leader.workspace());
Map<String, String> labelsHere = leadLabelsBySpace
.computeIfAbsent(leader.workspace(), k -> new LinkedHashMap<>());
leader.acceptedLabels().forEach(label -> labelsHere.put(label, name));
if (leader.tab() != null && !leader.tab().isBlank()) {
log.warn("lead '{}' (fleet.leaders.{}) still configures tab: \"{}\" — deprecated, "
+ "the lead tab label is now fixed to '{}'",
name, name, leader.tab(), FleetConfig.Leader.LEAD_TAB_LABEL);
}
});
Map<String, String> collaboratorTabToName = new LinkedHashMap<>();
@@ -283,13 +293,13 @@ final class FleetdAssembly {
? 10
: leaders.values().iterator().next().scanIntervalSeconds();
// This must use the lead daemon: scanning member tabs would demote the lead to a worker.
LeadTabScanner scanner = new LeadTabScanner(herdr, tabToName, collaboratorTabToName, Set.of(),
TimeUnit.SECONDS.toNanos(scanIntervalSeconds), ports.nanoClock());
LeadTabScanner scanner = new LeadTabScanner(herdr, leadLabelsBySpace, collaboratorTabToName,
Set.of(), TimeUnit.SECONDS.toNanos(scanIntervalSeconds), ports.nanoClock());
leads = scanner;
collaboratorTerminals = scanner::collaborators;
log.info("lead/collaborator scan: tabs {} host a lead, tabs {} host a collaborator "
+ "(rescan every {}s, shared fleet space)",
tabToName.keySet(), collaboratorTabToName.keySet(), scanIntervalSeconds);
log.info("lead/collaborator scan: space per lead {}, tabs {} host a collaborator "
+ "(rescan every {}s)",
spaceByLeadName, collaboratorTabToName.keySet(), scanIntervalSeconds);
} else {
leads = () -> leadTerminals;
collaboratorTerminals = Map::of;
@@ -32,6 +32,13 @@ public final class Authz {
REPLY,
/** A worker's mid-turn question to the primary. */
ASK,
/**
* Collect the messages queued for the caller's OWN pane, instead of having them typed into
* its terminal. Grouped with {@link #REPLY} and {@link #ASK} below as an only-as-itself
* action: the pane is always the caller's connection-resolved terminal, never an argument,
* so no caller can collect another pane's mail.
*/
INBOX,
/** Collect held replies from a session's inbox. */
DRAIN,
/** Read-only roster, profile, and identity observation: no ticket, task, or turn state. */
@@ -70,33 +77,61 @@ public final class Authz {
*/
public static final Predicate<String> NO_KNOWN_LEAD_OR_COLLABORATOR = target -> false;
/**
* The fail-closed classifier for an observer's {@code SEND}: answers no for every target, so
* the grant is refused unless a caller supplies a real one. {@code
* CallerResolver#observerSendTarget()} is the real one, read from the same maps {@code
* CallerResolver#resolve} consults, so a target that classifier accepts is one {@code resolve}
* would actually resolve as a lead ({@link Role#PRIMARY}) or as {@link Role#OBSERVER}.
*/
public static final Predicate<String> NO_OBSERVER_SEND_TARGET = target -> false;
/**
* Convenience form for a caller with no classifier to supply. Fails closed: a collaborator's
* {@code SEND} is refused, as if no terminal were a configured lead or collaborator — the
* same decision {@link #NO_KNOWN_LEAD_OR_COLLABORATOR} gives explicitly. Every other action's
* result is identical to the four-argument form's, since none of them consult the classifier.
* or an observer's {@code SEND} is refused, as if no terminal were a configured lead,
* collaborator, or observer-reachable target — the same decision
* {@link #NO_KNOWN_LEAD_OR_COLLABORATOR} and {@link #NO_OBSERVER_SEND_TARGET} give explicitly.
* Every other action's result is identical to the five-argument form's, since none of them
* consult either classifier.
*
* <p>Its default classifier denies every collaborator, so a caller enforcing authorization
* must use the four-argument form instead.
* <p>Its default classifiers deny every collaborator and every observer, so a caller
* enforcing authorization must use the five-argument form instead.
*/
public static boolean permits(Principal caller, Action action, String targetSession) {
return permits(caller, action, targetSession, NO_KNOWN_LEAD_OR_COLLABORATOR);
return permits(caller, action, targetSession, NO_KNOWN_LEAD_OR_COLLABORATOR, NO_OBSERVER_SEND_TARGET);
}
/**
* As {@link #permits(Principal, Action, String)}, with a real classifier for a collaborator's
* {@code SEND}. An observer's {@code SEND} still fails closed ({@link #NO_OBSERVER_SEND_TARGET}) —
* a caller enforcing both grants must use the five-argument form.
*/
public static boolean permits(Principal caller, Action action, String targetSession,
Predicate<String> knownLeadOrCollaborator) {
return permits(caller, action, targetSession, knownLeadOrCollaborator, NO_OBSERVER_SEND_TARGET);
}
/**
* Whether {@code caller} may perform {@code action} against {@code targetSession}.
*
* @param targetSession the session id in the request path; only consulted for the
* worker-scoped actions ({@code REPLY}, {@code ASK}) and for a
* collaborator's {@code SEND}, ignored otherwise, may be
* worker-scoped actions ({@code REPLY}, {@code ASK},
* {@code INBOX}), for a collaborator's {@code SEND}, and for an
* observer's {@code SEND}, ignored otherwise, may be
* {@code null}
* @param knownLeadOrCollaborator whether a terminal is a configured lead or collaborator —
* consulted only for a collaborator's {@code SEND}, to confine
* it to another named peer and never a spawned member's
* terminal
* @param observerSendTarget whether a terminal is one this daemon would itself resolve as
* a lead ({@link Role#PRIMARY}) or as {@link Role#OBSERVER} —
* consulted only for an observer's {@code SEND}, to confine it
* to a lead or another observer pane and never a collaborator,
* an architect, or a spawned member
*/
public static boolean permits(Principal caller, Action action, String targetSession,
Predicate<String> knownLeadOrCollaborator) {
Predicate<String> knownLeadOrCollaborator,
Predicate<String> observerSendTarget) {
if (caller == null || caller.isAnonymous()) {
return false; // authenticated as nothing ⇒ authorized for nothing
}
@@ -107,13 +142,15 @@ public final class Authz {
// would be a worker escalating into the orchestrator role.
case SPAWN, STOP, DRAIN, HANDOVER -> caller.isPrimary();
// Delivering a turn to a local session is open to the primary, the architect, and a
// collaborator whose target is itself a configured lead or collaborator: the architect
// delegates to workers (that is the role's point); a collaborator may reach only
// another named peer, never a spawned member's terminal. A worker is excluded —
// sending would be it escalating.
// Delivering a turn to a local session is open to the primary and the architect
// unconditionally. A collaborator may reach only a target that is itself a configured
// lead or collaborator, never a spawned member's terminal. An observer may reach only
// a target that would itself resolve as a lead or as another observer, never a
// collaborator, an architect, or a spawned member. A worker is excluded from every
// case — sending would be it escalating into the orchestrator role.
case SEND -> caller.isPrimary() || caller.isArchitect()
|| (caller.isCollaborator() && knownLeadOrCollaborator.test(targetSession));
|| (caller.isCollaborator() && knownLeadOrCollaborator.test(targetSession))
|| (caller.isObserver() && observerSendTarget.test(targetSession));
// Resolving a worker's blocked question is part of delegating to it, open to the same
// two roles that may stand up that delegation in the first place. Not a collaborator:
@@ -132,7 +169,7 @@ public final class Authz {
// collaborator's own pane passes through the same check, so each can answer a funnel
// that delegated to it. An unnamed primary (token/loopback, no pane) owns nothing and
// is still excluded.
case REPLY, ASK -> caller.ownsSession(targetSession);
case REPLY, ASK, INBOX -> caller.ownsSession(targetSession);
// READ is roster, profile, and identity observation — fleet_list, fleet_profiles, and
// fleet_whoami — and carries no secrets: no ticket reply, no pending question, and no
@@ -270,6 +270,36 @@ public final class CallerResolver {
|| collaboratorTerminals.get().containsKey(target);
}
/**
* Whether {@code target} names a terminal an observer may {@code SEND} to: one this resolver
* would itself resolve as a lead ({@link Role#PRIMARY}) or as {@link Role#OBSERVER}. Read from
* the same maps and functions {@link #resolve} consults, and in the same order, so a target
* this accepts is exactly one {@code resolve} would hand back one of those two roles for, and
* the reverse.
*
* <p>A live spawned member is refused first, whatever a tab map says about its terminal — the
* order {@link #resolve} itself uses. A pane named as a lead is then accepted even when it is
* also bound to an architect slot, because that is the role {@code resolve} gives it.
*/
public Predicate<String> observerSendTarget() {
return target -> target != null
&& spawnedMemberRole.apply(target) == null
&& (leadTerminals.get().containsKey(target)
|| (!boundToArchitectSlot(target)
&& !collaboratorTerminals.get().containsKey(target)));
}
/**
* Whether {@code terminal} is bound to a configured slot the live roster still confirms as an
* architect — the one classifier {@link #observerSendTarget()} and {@code FleetMcp}'s
* {@code panes} row both read, so a pane's reported role and its {@code SEND} reachability can
* never drift apart.
*/
public boolean boundToArchitectSlot(String terminal) {
String slot = architectTerminals.get().get(terminal);
return slot != null && memberSlotRoles.apply(slot) == MemberRole.ARCHITECT;
}
/**
* Resolve the caller of a request.
*
@@ -12,9 +12,10 @@ package dev.ltms.fleet.auth;
public enum Role {
/**
* The orchestrating session. Established either by being a loopback caller that is not a
* worker pane (under {@code loopback-trust}) or by presenting a valid bearer token (under
* {@code token} mode).
* The orchestrating session. Established either by being a loopback caller that resolves to
* no herdr pane at all (under {@code loopback-trust}) or by presenting a valid bearer token
* (under {@code token} mode). A loopback caller that does own a pane, but matches none of the
* roles below, resolves to {@link #OBSERVER} instead.
*/
PRIMARY,
@@ -49,10 +50,11 @@ public enum Role {
* A loopback pane that resolved to none of the roles above: not a live spawned member, not a
* configured lead, not a bound architect slot, not a configured collaborator tab. Unforgeable
* like a worker's — derived from the connection's pane, never from a request argument, and
* honoured regardless of auth mode. May {@code READ} and {@code METRICS}, and {@code REPLY}/
* {@code ASK} only as its own pane; may not {@code SPAWN}/{@code STOP}/{@code DRAIN}/
* {@code HANDOVER}, {@code SEND}, poll a ticket ({@code TASK_READ}), or reach the coordination
* broker ({@code COORD_SEND}/{@code COORD_READ}).
* honoured regardless of auth mode. May {@code READ} and {@code METRICS}, {@code REPLY}/
* {@code ASK} only as its own pane, and {@code SEND} only to a target that would itself
* resolve as a lead ({@link #PRIMARY}) or as {@code OBSERVER}; may not {@code SPAWN}/
* {@code STOP}/{@code DRAIN}/{@code HANDOVER}, poll a ticket ({@code TASK_READ}), or reach the
* coordination broker ({@code COORD_SEND}/{@code COORD_READ}).
*/
OBSERVER,
@@ -28,6 +28,7 @@ import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
@@ -1092,8 +1093,8 @@ public record FleetConfig(
}
/**
* One entry of the CB-530 {@code leaders:} registry — a pane that orchestrates rather than one
* that is orchestrated.
* One entry of the {@code leaders:} registry — a pane that orchestrates rather than one that is
* orchestrated.
*
* <p>Why a registry and not a second {@code primary:}: {@code primary.terminal} is singular by
* construction, so a session in any other pane resolves as a worker. That is correct while one
@@ -1103,46 +1104,49 @@ public record FleetConfig(
* <p>{@code kind} and {@code model} are descriptive only: they document what runs in the pane
* and are reported back by {@code fleet_whoami}.
*
* <p><b>A lead is now also creatable (CB-557).</b> Before, nothing spawned one — a lead
* pre-existed, which is why it had to be recognised by configuration rather than created. With
* {@code profile} and {@code instances} the daemon may stand one up when none is live, so the
* pane no longer has to exist before the daemon does. Recognition still comes first: a lead
* already running in its configured {@code tab} is adopted, and only the shortfall is launched.
* <p>A lead with {@code profile} and {@code instances} set may be launched by the daemon when
* none is live; recognition always comes first, so only the shortfall is launched.
*
* <p><b>{@code tab} replaced {@code terminal} (CB-579).</b> A herdr {@code terminal_id} changes
* every time the lead's session restarts, so pinning one cost a config edit and a daemon restart
* per restart. A tab is stable: a human opens it once, it holds exactly one pane, and its label
* survives restarts of the agent inside it — so identity is now the tab label alone.
* <p>Every lead's tab is labelled {@link #LEAD_TAB_LABEL}, a fixed constant — not a per-entry
* config value. {@code workspace} is therefore what tells one lead from another: two leaders
* sharing one space would both resolve to the one tab named {@code lead} there, so only one
* could ever be found. {@code tab} is a deprecated legacy label, still matched within this
* lead's own space alongside the constant.
*
* @param profile the {@code profiles:} entry to launch this lead on when one must
* be created; {@code null} ⇒ recognise-only, never create.
* <p>fleetd #176: also the field {@code Fleetd.leadSeatLookup} reads
* to learn which account this lead's own live session shares — set it
* <p>Also the field {@code Fleetd.leadSeatLookup} reads to learn
* which account this lead's own live session shares — set it
* (safely, even on an already-running recognise-only lead: naming a
* profile here never starts anything beyond {@code instances}) so a
* {@code subscription: true} worker profile sharing its
* {@code effectiveCredentialId()} has this lead's seat subtracted from
* {@code fleet_list}'s {@code free}. {@code null} here also means this
* lead's seat cannot be derived and is not counted.
* @param tab the exact tab label hosting this lead, matched case-insensitively;
* the only field identity depends on. Required — a lead with no
* {@code tab} can never be discovered, launched or not
* @param tab deprecated legacy tab label, matched case-insensitively within
* this lead's own space alongside {@link #LEAD_TAB_LABEL}. Optional —
* {@code null}/blank means only the constant is accepted
* @param instances how many of this lead should be live (default 1). The daemon
* launches only the shortfall, so a restart adopts rather than doubles
* @param tabPrefix lead-tab naming convention checked against member labels. Lead
* identity uses {@code tab}. Default {@code "lead:"}
* identity uses {@link #acceptedLabels()}. Default {@code "lead:"}
* @param scanIntervalSeconds how long a tab scan is cached before herdr is asked again; also the
* worst case before a newly-labelled tab is recognised. Default 10
* @param kind which agent runs there ({@code claude}, {@code opencode}, …)
* @param model the model or selector it runs, for operators reading the roster
* @param workspace the space this lead's tab lives in — the uniqueness boundary
* identity now depends on. Default {@link #DEFAULT_WORKSPACE}
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Leader(String profile, String tab, Integer instances, String tabPrefix,
Integer scanIntervalSeconds, String kind, String model,
String workspace, String cwd) {
/** The tab label every lead is found by, and an auto-launched instance is created with. */
public static final String LEAD_TAB_LABEL = "lead";
/**
* Where an auto-launched lead's tab is created (CB-558). It defaults to the SAME shared
* Where an auto-launched lead's tab is created. It defaults to the SAME shared
* {@code "fleet"} space the members use, so the operator sees one "session" with many tabs.
* The scanner no longer excludes member spaces — it tells a lead from a member by the exact
* tab label, so a lead sharing the members' space is still discovered (see LeadLauncher).
@@ -1170,9 +1174,23 @@ public record FleetConfig(
return profile != null && !profile.isBlank() && instances > 0;
}
/** The tab label an auto-launched instance of this lead gets — its configured {@code tab}. */
/** The tab label an auto-launched instance of this lead gets. */
public String tabLabel() {
return tab;
return LEAD_TAB_LABEL;
}
/**
* The normalised labels (stripped, lower-cased) a tab in this lead's own space may carry to
* be recognised as this lead: {@link #LEAD_TAB_LABEL} first, plus the deprecated {@code tab}
* when configured and different. Every matcher in this class's callers reads this method —
* none re-derives the set.
*/
public List<String> acceptedLabels() {
String normalizedTab = (tab == null) ? null : tab.toLowerCase(Locale.ROOT);
if (normalizedTab == null || normalizedTab.equals(LEAD_TAB_LABEL)) {
return List.of(LEAD_TAB_LABEL);
}
return List.of(LEAD_TAB_LABEL, normalizedTab);
}
}
@@ -2747,23 +2765,42 @@ public record FleetConfig(
/**
* Reject a member tab-label template that could render as a configured lead or collaborator
* tab or match a lead-tab naming convention, and reject two {@code fleet.leaders} or
* {@code fleet.collaborators} entries — across either registry — that share one exact tab.
* tab, as the fixed lead tab label, or that matches a lead-tab naming convention; reject two
* {@code fleet.leaders} entries that share one space; reject two {@code fleet.collaborators}
* entries — or a lead and a collaborator — that share one exact tab; and reject a collaborator
* tab equal to the fixed lead tab label.
*
* <p>{@code fleet.collaborators} has no {@code tabPrefix}: identity is matched on the exact
* {@code tab} alone, so only the exact-render check applies there, not the prefix check.
*
* @throws IllegalStateException when the fleet template or a profile {@code tabLabel} override
* can render as a configured lead or collaborator tab or match a
* lead-tab prefix, or when two entries — of either registry, or
* one of each — carry the same exact {@code tab}
* (case-insensitively)
* can render as a configured lead or collaborator tab, as the
* fixed lead tab label, or match a lead-tab prefix; when two
* leaders share one space; when two collaborators (or a lead and
* a collaborator) carry the same exact {@code tab}
* (case-insensitively); or when a collaborator's {@code tab}
* equals the fixed lead tab label
*/
public void validateLeadTabPrefixes() {
if (fleet == null) {
return;
}
List<String> bad = new ArrayList<>();
if (templateCanRenderAs(fleet.tabLabel(), Leader.LEAD_TAB_LABEL)) {
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as \""
+ Leader.LEAD_TAB_LABEL + "\", the fixed lead tab label");
}
profiles().entrySet().stream()
.map(Map.Entry::getKey)
.sorted()
.forEach(p -> {
String label = profiles().get(p).tabLabel();
if (templateCanRenderAs(label, Leader.LEAD_TAB_LABEL)) {
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
+ "\", which can render as \"" + Leader.LEAD_TAB_LABEL
+ "\", the fixed lead tab label");
}
});
fleet.leaders().forEach((leadName, leader) -> {
if (leader == null) {
return;
@@ -2798,6 +2835,10 @@ public record FleetConfig(
return;
}
String tab = collaborator.tab();
if (tab != null && tab.equalsIgnoreCase(Leader.LEAD_TAB_LABEL)) {
bad.add("fleet.collaborators." + collabName + ".tab=\"" + tab + "\" is the fixed "
+ "lead tab label — a collaborator there would shadow a lead");
}
if (templateCanRenderAs(fleet.tabLabel(), tab)) {
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as the tab of "
+ "collaborator '" + collabName + "' (\"" + tab + "\")");
@@ -2827,18 +2868,20 @@ public record FleetConfig(
for (int i = 0; i < leadNames.size(); i++) {
String nameA = leadNames.get(i);
Leader a = fleet.leaders().get(nameA);
if (a == null || a.tab() == null || a.tab().isBlank()) {
if (a == null) {
continue;
}
for (int j = i + 1; j < leadNames.size(); j++) {
String nameB = leadNames.get(j);
Leader b = fleet.leaders().get(nameB);
if (b == null || b.tab() == null || b.tab().isBlank()) {
if (b == null) {
continue;
}
if (a.tab().equalsIgnoreCase(b.tab())) {
collisions.add("lead '" + nameA + "' and lead '" + nameB + "' both use tab \""
+ a.tab() + "\"");
if (a.workspace().equalsIgnoreCase(b.workspace())) {
collisions.add("lead '" + nameA + "' and lead '" + nameB + "' share workspace \""
+ a.workspace() + "\" — both would resolve to the tab named \""
+ Leader.LEAD_TAB_LABEL + "\" in that space, so only one could ever be "
+ "found");
}
}
}
@@ -2882,9 +2925,9 @@ public record FleetConfig(
return;
}
throw new IllegalStateException("refusing to start: " + String.join("; ", collisions)
+ ". Tab identity is matched exactly, so only one of two entries sharing a tab can "
+ "ever be found — the other is silently unreachable. Give each lead and "
+ "collaborator its own exact tab.");
+ ". Identity is matched exactly, so only one of two entries sharing a space or a "
+ "tab can ever be found — the other is silently unreachable. Give each lead its "
+ "own space, and each collaborator its own exact tab.");
}
private static boolean templateCanRenderAs(String template, String tab) {
@@ -2913,30 +2956,32 @@ public record FleetConfig(
}
/**
* Reject a profile that places its members by {@code "pane"} while any {@code fleet.leaders}
* or {@code fleet.collaborators} entry names a {@code tab}. A pane-placed member lands inside
* the focused tab rather than its own, so it can land inside a lead's or collaborator's own
* labelled tab. {@link dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead or collaborator
* purely by that tab's label — it does not exclude the member space — so a member that ends up
* there, while its pane carries no entry in the spawned-member roster, is read back as that
* lead or collaborator and granted that identity's authority.
* Reject a profile that places its members by {@code "pane"} while {@code fleet.leaders} has
* any entry, or any {@code fleet.collaborators} entry names a {@code tab}. A pane-placed
* member lands inside the focused tab rather than its own, so it can land inside a lead's or
* collaborator's own labelled tab. {@link dev.ltms.fleet.herdr.LeadTabScanner} identifies a
* lead or collaborator purely by that tab's label — it does not exclude the member space — so
* a member that ends up there, while its pane carries no entry in the spawned-member roster,
* is read back as that lead or collaborator and granted that identity's authority.
*
* <p>Only an entry with a non-blank {@code tab} is in scope: one with no {@code tab} feeds
* <p>Every {@code fleet.leaders} entry is in scope regardless of its own {@code tab} field:
* {@link Leader#acceptedLabels()} always includes {@link Leader#LEAD_TAB_LABEL}. Only a
* collaborator with a non-blank {@code tab} is in scope: one with no {@code tab} feeds
* nothing into {@link dev.ltms.fleet.herdr.LeadTabScanner}, so it creates no hazard here.
*
* @throws IllegalStateException when any {@code profiles:} entry is pane-placed while any
* {@code fleet.leaders} or {@code fleet.collaborators} entry
* names a non-blank {@code tab}
* @throws IllegalStateException when any {@code profiles:} entry is pane-placed while
* {@code fleet.leaders} is non-empty, or any
* {@code fleet.collaborators} entry names a non-blank
* {@code tab}
*/
public void validatePanePlacementAgainstLeadTabs() {
if (fleet == null) {
return;
}
boolean anyLeaderHasTab = fleet.leaders().values().stream()
.anyMatch(leader -> leader != null && leader.tab() != null && !leader.tab().isBlank());
boolean anyLead = !fleet.leaders().isEmpty();
boolean anyCollaboratorHasTab = fleet.collaborators().values().stream()
.anyMatch(c -> c != null && c.tab() != null && !c.tab().isBlank());
if (!anyLeaderHasTab && !anyCollaboratorHasTab) {
if (!anyLead && !anyCollaboratorHasTab) {
return;
}
List<String> bad = new ArrayList<>();
@@ -2953,8 +2998,9 @@ public record FleetConfig(
+ "pane-placed member can land inside that labelled tab, and while its pane "
+ "carries no entry in the spawned-member roster, it is read back as the lead or "
+ "collaborator and granted that identity's authority. Set placement: tab for "
+ "each named profile, or remove the tab from every fleet.leaders and "
+ "fleet.collaborators entry.");
+ "each named profile — the only fix when a lead triggered this, since a lead's "
+ "tab label is fixed regardless of its own tab: field. A collaborator's tab can "
+ "still be removed instead.");
}
/**
@@ -3061,14 +3107,13 @@ public record FleetConfig(
* so duplicates are unrepresentable by construction once loaded — and {@link #load(Path)}
* already rejects a duplicated slot name at parse time, before the map collapses.
*
* <p>Also rejects a {@code fleet.collaborators} entry with no (or a blank) {@code tab}. A
* {@code profile}-less lead is still useful recognise-only — {@code tab} is the only field
* that matters to it either way. A collaborator carries no other field at all, so a blank
* {@code tab} leaves nothing for the entry to mean.
* <p>A lead's {@code profile} is optional — a {@code profile}-less lead is still useful
* recognise-only. Also rejects a {@code fleet.collaborators} entry with no (or a blank)
* {@code tab}: a collaborator carries no other field at all, so a blank {@code tab} leaves
* nothing for the entry to mean.
*
* @throws IllegalStateException when a slot names no profile or an unknown one, when a lead
* can be neither found nor created, or when a collaborator names
* no tab, naming the offending entry
* @throws IllegalStateException when a slot or a lead references an unknown profile, or a
* collaborator names no tab, naming the offending entry
*/
public void validateMembers() {
if (fleet == null) {
@@ -3101,10 +3146,6 @@ public record FleetConfig(
+ "', which is not a configured profiles: entry (have: " + profiles.keySet()
+ ").");
}
if (leader.tab() == null || leader.tab().isBlank()) {
bad.add("fleet.leaders." + name + " has no tab: — a lead is now found (and, if "
+ "auto-launched, labelled) purely by its tab, so every entry must name one.");
}
});
fleet.collaborators().forEach((name, collaborator) -> {
if (collaborator == null) {
@@ -137,6 +137,18 @@ public final class AgentControl {
return result.path("read").path("text").asText("");
}
/**
* Read an agent's terminal with its ANSI styling kept, instead of the stripped text {@link
* #read} returns. Needed when a caller must tell apart text the pane draws dim (a placeholder
* hint) from text drawn plain (the operator's own typing).
*
* @param source one of {@code visible|recent|recent_unwrapped|detection}
*/
public String readWithStyling(String target, String source) {
JsonNode result = agentCall("agent.read", target, Map.of("source", source, "strip_ansi", false));
return result.path("read").path("text").asText("");
}
/** Current agent record (status, session UUID, pane). */
public Agent get(String target) {
return Agent.from(agentCall("agent.get", target, Map.of()).get("agent"));
@@ -25,14 +25,12 @@ import java.util.function.Supplier;
* by first starting the session and asking it. Scanning closes that loop: label the tab, and the
* pane is recognised on the next resolve.
*
* <p><strong>CB-579 — matched by name, not prefix.</strong> This used to strip one shared
* {@code tabPrefix} off a label to derive the lead's name, and merged a config-supplied
* {@code terminal_id} pin over every scan result so the pin could never expire. Both are gone: each
* lead now configures its own exact {@code tab} label ({@code fleet.leaders.<name>.tab}), so this
* class is handed a {@code tab → name} map up front and matches labels against it exactly
* (case-insensitively). There is no merge step — a scan result is the whole answer. That is the
* fix for the bug this replaces: a {@code terminal_id} pin surviving in config after the pane it
* named was gone, so the daemon kept treating a dead session as a live lead forever.
* <p><strong>Matched by label within a space, not by a shared prefix.</strong> Each lead's accepted
* labels (the fixed {@code lead} label, plus a deprecated {@code tab} when still configured) are
* matched exactly (case-insensitively) against tabs in that lead's own space only — a tab named
* {@code lead} in one space never resolves to another space's lead. A scan result is the whole
* answer; nothing is merged in from configuration between scans, so a tab that is gone drops out on
* the very next scan instead of lingering forever.
*
* <p><strong>Direction of trust.</strong> The label names the lead; it never <em>grants</em>
* anything a pane could take for itself. Three properties keep that honest:
@@ -103,7 +101,8 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
private record Entry(String name, Kind kind) {}
private final HerdrClient herdr;
private final Map<String, Entry> tabToEntry;
private final Map<String, Map<String, String>> leadLabelsBySpace;
private final Map<String, String> collaboratorTabToName;
private final Set<String> excludedWorkspaceLabels;
private final long ttlNanos;
private final LongSupplier clock;
@@ -124,15 +123,17 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
/**
* @param herdr the herdr client to query ({@code workspace.list},
* {@code tab.list}, {@code pane.list} — all read-only)
* @param tabToName every configured lead's exact tab label → its name
* ({@code fleet.leaders.<name>.tab}), matched case-insensitively
* @param leadLabelsBySpace each configured lead's accepted tab labels, keyed by the
* lead's own space label, then by label, to its name — matched
* case-insensitively on both the space and the label. A tab
* matches a lead only within that lead's own space
* @param excludedWorkspaceLabels workspaces never scanned — the configured worker spaces
* @param ttlNanos how long a scan result is reused before the next one
* @param clock nanosecond time source ({@code System::nanoTime} in production)
*/
public LeadTabScanner(HerdrClient herdr, Map<String, String> tabToName,
public LeadTabScanner(HerdrClient herdr, Map<String, Map<String, String>> leadLabelsBySpace,
Set<String> excludedWorkspaceLabels, long ttlNanos, LongSupplier clock) {
this(herdr, tabToName, Map.of(), excludedWorkspaceLabels, ttlNanos, clock);
this(herdr, leadLabelsBySpace, Map.of(), excludedWorkspaceLabels, ttlNanos, clock);
}
/**
@@ -140,14 +141,15 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
* for configured collaborator tabs in the same pass.
*
* @param collaboratorTabToName every configured collaborator's exact tab label → its name
* ({@code fleet.collaborators.<name>.tab}), matched the same way as
* {@code tabToName}
* ({@code fleet.collaborators.<name>.tab}), matched
* case-insensitively in any space
*/
public LeadTabScanner(HerdrClient herdr, Map<String, String> tabToName,
public LeadTabScanner(HerdrClient herdr, Map<String, Map<String, String>> leadLabelsBySpace,
Map<String, String> collaboratorTabToName,
Set<String> excludedWorkspaceLabels, long ttlNanos, LongSupplier clock) {
this.herdr = herdr;
this.tabToEntry = buildTabIndex(tabToName, collaboratorTabToName);
this.leadLabelsBySpace = buildLeadIndex(leadLabelsBySpace);
this.collaboratorTabToName = normalizedLabelMap(collaboratorTabToName);
this.excludedWorkspaceLabels = excludedWorkspaceLabels == null
? Set.of() : Set.copyOf(excludedWorkspaceLabels);
this.ttlNanos = ttlNanos;
@@ -155,30 +157,46 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
}
/**
* Keys stripped and lower-cased once, so every lookup is a plain map hit. Leads and
* collaborators merge into a single index, so {@link #scan()} matches both kinds in one pass
* over the tab list; a label naming both a lead and a collaborator takes the lead entry —
* leads are put last, so a colliding key's lead entry is the one that overwrites — since a lead
* can already do everything a collaborator can. Config validation already refuses a lead and a
* collaborator sharing one exact tab, so this ordering is defence in depth, not the control.
* Space and label keys stripped and lower-cased once, so every lookup is a plain map hit. A
* space with no usable labels is simply absent — {@link #leadLabelsFor} then finds nothing for
* it, which is also what a space with a {@code null} label gets.
*/
private static Map<String, Entry> buildTabIndex(Map<String, String> tabToName,
Map<String, String> collaboratorTabToName) {
Map<String, Entry> out = new LinkedHashMap<>();
putNormalized(out, collaboratorTabToName, Kind.COLLABORATOR);
putNormalized(out, tabToName, Kind.LEAD);
private static Map<String, Map<String, String>> buildLeadIndex(
Map<String, Map<String, String>> leadLabelsBySpace) {
Map<String, Map<String, String>> out = new LinkedHashMap<>();
if (leadLabelsBySpace == null) {
return Map.of();
}
leadLabelsBySpace.forEach((space, labelsToName) -> {
if (space == null || space.isBlank()) {
return;
}
Map<String, String> normalized = normalizedLabelMap(labelsToName);
if (!normalized.isEmpty()) {
out.put(space.strip().toLowerCase(Locale.ROOT), normalized);
}
});
return Collections.unmodifiableMap(out);
}
private static void putNormalized(Map<String, Entry> out, Map<String, String> tabToName, Kind kind) {
if (tabToName == null) {
return;
private static Map<String, String> normalizedLabelMap(Map<String, String> labelToName) {
Map<String, String> out = new LinkedHashMap<>();
if (labelToName != null) {
labelToName.forEach((label, name) -> {
if (label != null && !label.isBlank() && name != null && !name.isBlank()) {
out.put(label.strip().toLowerCase(Locale.ROOT), name);
}
});
}
tabToName.forEach((tab, name) -> {
if (tab != null && !tab.isBlank() && name != null && !name.isBlank()) {
out.put(tab.strip().toLowerCase(Locale.ROOT), new Entry(name, kind));
}
});
return Collections.unmodifiableMap(out);
}
/** The accepted lead labels configured for {@code spaceLabel}, or an empty map for no match. */
private Map<String, String> leadLabelsFor(String spaceLabel) {
if (spaceLabel == null) {
return Map.of();
}
return leadLabelsBySpace.getOrDefault(spaceLabel.strip().toLowerCase(Locale.ROOT), Map.of());
}
/**
@@ -241,9 +259,10 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
if (ws.workspaceId() == null || excludedWorkspaceLabels.contains(ws.label())) {
continue;
}
Map<String, String> leadLabelsHere = leadLabelsFor(ws.label());
for (JsonNode t : herdr.call("tab.list", Map.of("workspace_id", ws.workspaceId())).path("tabs")) {
Tab tab = Tab.from(t);
Entry entry = entryOf(tab.label());
Entry entry = entryOf(tab.label(), leadLabelsHere);
if (entry != null && tab.tabId() != null) {
entryByTab.put(tab.tabId(), entry);
}
@@ -296,19 +315,27 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
}
/**
* The entry a tab label declares, or {@code null} if it names neither a configured lead nor a
* configured collaborator.
* The entry a tab label declares within one space, or {@code null} if it names neither a lead
* accepted in {@code leadLabelsHere} nor a configured collaborator.
*
* <p>Exact match (case-insensitive, ends stripped) against {@link #tabToEntry} — no prefix
* stripping, so an operator's {@code "lead: something-else"} tab is never mistaken for a
* configured lead just because it shares a prefix. The match strips a trailing
* {@link PendingCloseMarker} first, so a tab {@code LeadLauncher} has flagged as maybe-dead but
* not yet closed keeps resolving normally while that reconcile is pending.
* <p>Exact match (case-insensitive, ends stripped) — no prefix stripping, so an operator's
* {@code "lead: something-else"} tab is never mistaken for a configured lead just because it
* shares a prefix. The match strips a trailing {@link PendingCloseMarker} first, so a tab
* {@code LeadLauncher} has flagged as maybe-dead but not yet closed keeps resolving normally
* while that reconcile is pending. A lead match wins over a collaborator match for the same
* label — a lead can already do everything a collaborator can, and config validation refuses a
* lead and a collaborator sharing one exact tab in the first place.
*/
private Entry entryOf(String label) {
private Entry entryOf(String label, Map<String, String> leadLabelsHere) {
if (label == null) {
return null;
}
return tabToEntry.get(PendingCloseMarker.strip(label).toLowerCase(Locale.ROOT));
String normalized = PendingCloseMarker.strip(label).toLowerCase(Locale.ROOT);
String leadName = leadLabelsHere.get(normalized);
if (leadName != null) {
return new Entry(leadName, Kind.LEAD);
}
String collaboratorName = collaboratorTabToName.get(normalized);
return collaboratorName == null ? null : new Entry(collaboratorName, Kind.COLLABORATOR);
}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -145,6 +146,52 @@ public final class PaneLocator {
return ancestry;
}
/**
* Every tab herdr tracks across every searched daemon, keyed by tab id, to its display label —
* the pane-discovery surface behind {@code GET /agents} and {@code fleet_list}'s {@code panes}
* row. Collapses to one scan in the single-daemon deployment, the same as
* {@link #terminalForPid}. A tab herdr reports with no label maps to a {@code null} value here;
* a tab with no {@code tab_id} is skipped.
*/
public Map<String, String> tabLabelsByTabId() {
Map<String, String> out = new LinkedHashMap<>();
for (HerdrClient herdr : herdrs) {
for (JsonNode w : herdr.call("workspace.list").path("workspaces")) {
String workspaceId = w.path("workspace_id").asText(null);
if (workspaceId == null) {
continue;
}
for (JsonNode t : herdr.call("tab.list", Map.of("workspace_id", workspaceId)).path("tabs")) {
Tab tab = Tab.from(t);
if (tab.tabId() != null) {
out.put(tab.tabId(), tab.label());
}
}
}
}
return out;
}
/**
* Every workspace ("space") herdr tracks across every searched daemon, keyed by workspace id,
* to its display label — the human-readable name behind {@code fleet_list}'s {@code panes} row,
* next to herdr's own internal {@code workspaceId}. Collapses to one scan in the single-daemon
* deployment, the same as {@link #terminalForPid}. A workspace herdr reports with no label maps
* to a {@code null} value here; a workspace with no {@code workspace_id} is skipped.
*/
public Map<String, String> workspaceLabelsByWorkspaceId() {
Map<String, String> out = new LinkedHashMap<>();
for (HerdrClient herdr : herdrs) {
for (JsonNode w : herdr.call("workspace.list").path("workspaces")) {
Workspace workspace = Workspace.from(w);
if (workspace.workspaceId() != null) {
out.put(workspace.workspaceId(), workspace.label());
}
}
}
return out;
}
/** Whether a pane owns one of the scanned pid's ancestors, or the check of it failed outright. */
private enum Ownership { OWNS, DOES_NOT_OWN, UNKNOWN }
@@ -0,0 +1,231 @@
package dev.ltms.fleet.herdr;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Whether an agent pane's input box is clear for a delivery.
*
* <p>{@link AgentControl#send} pastes its text and submits it in the same call, so a delivery into a
* pane whose input box already holds characters submits those characters too. {@link
* AgentStatus#injectable()} cannot see that: it describes the agent, and an agent waiting at its
* prompt reports the same status whether its box is empty or holds a half-typed line. This reads the
* box itself.
*
* <p>Only a box that is positively empty clears the gate. A box with content, a pane this cannot
* recognise, and a failed read all hold the delivery, because a held delivery is recoverable and a
* submitted half-line is not. Every caller must therefore be a path that retries.
*
* <p>A pane that holds for {@link #HOLD_WARN_STREAK} consecutive checks gets one warning, so a box
* that never clears is visible instead of silent. The warning repeats only after the box has cleared
* again.
*/
public final class PromptBox {
private static final Logger log = LoggerFactory.getLogger(PromptBox.class);
/**
* herdr {@code agent.read} source, read with its ANSI styling kept. The input box is always
* drawn here, carrying transcript scrollback above it, which is why only the last box line is
* the live one. Styling must survive the read because the pane draws a placeholder hint — the
* pane's own last submitted prompt — in the same spot as unsubmitted text, dimmed; only the
* escape codes tell the two apart.
*/
static final String PROBE_SOURCE = "visible";
/** Consecutive holds for one target before one warning is logged. */
static final int HOLD_WARN_STREAK = 20;
/**
* Input box markers, each matched only as a line's first characters once any leading ANSI
* escape codes are skipped: the caret the current TUI draws, and the bordered box an older one
* drew. A marker further along a line is transcript text, such as a caret inside something the
* operator quoted.
*/
private static final List<String> BOX_MARKERS = List.of("❯", "│ >");
/** Marker of a turn that is still generating; a box drawn under it is not a settled prompt. */
private static final String ACTIVE_TURN_MARKER = "esc to interrupt";
/** Block glyphs a terminal capture can leave in an otherwise empty box for the cursor cell. */
private static final String CURSOR_GLYPHS = "█▉▊▋▌▍▎▏";
/** An SGR escape sequence, e.g. {@code ESC[2m} (faint) or {@code ESC[0m} (reset). */
private static final Pattern SGR = Pattern.compile("\u001b\\[([0-9;]*)m");
/** The SGR code that dims text — herdr's placeholder hint is drawn inside a span of this. */
private static final String FAINT_CODE = "2";
/** The SGR code (or an empty code list) that clears every attribute, including faint. */
private static final List<String> RESET_CODES = List.of("", "0");
/** What a box holds: nothing, unsubmitted characters, or a pane this cannot read as a box. */
public enum State { EMPTY, DRAFT, UNREADABLE }
/** A box reading: its state, and how many characters it holds ({@code 0} unless {@code DRAFT}). */
public record Reading(State state, int characters) {
}
private final AgentControl agents;
/** Consecutive holds per target, so a box that never clears can be warned about once. */
private final Map<String, Integer> holdStreaks = new ConcurrentHashMap<>();
public PromptBox(AgentControl agents) {
this.agents = agents;
}
/**
* Whether {@code target}'s input box is empty, so a delivery would submit only its own text.
* {@code false} means hold and come back; it never means the delivery failed.
*/
public boolean clearToSubmit(String target) {
Reading reading = inspect(target);
if (reading.state() == State.EMPTY) {
holdStreaks.remove(target);
return true;
}
int streak = holdStreaks.merge(target, 1, Integer::sum);
if (streak == HOLD_WARN_STREAK) {
log.warn("prompt box of {} has held a delivery {} times in a row ({}, {} character(s) in the box)"
+ " — nothing is lost, delivery resumes once the box is empty",
target, streak, reading.state(), reading.characters());
} else {
log.debug("prompt box of {} is {} ({} character(s)), holding delivery {}",
target, reading.state(), reading.characters(), streak);
}
return false;
}
/** Read and classify {@code target}'s pane. A read failure reads as {@link State#UNREADABLE}. */
private Reading inspect(String target) {
String pane;
try {
pane = agents.readWithStyling(target, PROBE_SOURCE);
} catch (RuntimeException e) {
log.debug("prompt box read for {} failed, holding delivery: {}", target, e.getMessage());
return new Reading(State.UNREADABLE, 0);
}
return classify(pane);
}
/**
* Classify a Claude Code TUI pane region. Pure, so it is unit-testable without herdr.
*
* <p>{@link State#EMPTY} needs two positive signals: the pane's last box line holds nothing after
* its marker, and nothing below that line says a turn is still generating. Everything else is
* {@link State#UNREADABLE} — a blank capture, or a region with no box line at all — so a pane this
* does not understand holds the delivery rather than guessing it is safe.
*
* <p>The generating marker is looked for only from the box line down. Above it is scrollback, where
* an earlier turn's marker survives; treating that as a live turn would make {@link State#EMPTY}
* unreachable and hold every delivery forever.
*
* <p>Whitespace, a trailing box border and a cursor block count as nothing. A placeholder hint —
* text the pane draws faint, in the same spot as unsubmitted text — also counts as nothing: only
* a character drawn outside a faint span is the operator's own typing.
*/
static Reading classify(String pane) {
if (pane == null || pane.isBlank()) return new Reading(State.UNREADABLE, 0);
int box = lastBoxLineStart(pane);
if (box < 0) return new Reading(State.UNREADABLE, 0);
String fromBox = pane.substring(box);
if (fromBox.toLowerCase().contains(ACTIVE_TURN_MARKER)) return new Reading(State.UNREADABLE, 0);
String content = boxContent(firstLine(fromBox));
return content.isEmpty() ? new Reading(State.EMPTY, 0) : new Reading(State.DRAFT, content.length());
}
/** Offset of the last line starting with a box marker, or {@code -1} if the region has none. */
private static int lastBoxLineStart(String pane) {
int found = -1;
for (int start = 0; start <= pane.length(); ) {
int end = pane.indexOf('\n', start);
String line = pane.substring(start, end < 0 ? pane.length() : end);
if (markerLength(line) > 0) found = start;
if (end < 0) break;
start = end + 1;
}
return found;
}
/**
* Length of the marker prefix — any leading SGR escape codes, then a box marker — this line
* starts with, or {@code 0} if it starts with neither. The colour drawn on the caret itself
* (e.g. an empty box's grey) sits before the marker glyph, so it must be skipped before the
* marker can match.
*/
private static int markerLength(String line) {
int skip = leadingEscapeLength(line);
for (String marker : BOX_MARKERS) {
if (line.startsWith(marker, skip)) return skip + marker.length();
}
return 0;
}
/** Length of the run of SGR escape codes starting at the beginning of {@code line}. */
private static int leadingEscapeLength(String line) {
Matcher m = SGR.matcher(line);
int pos = 0;
while (m.find(pos) && m.start() == pos) pos = m.end();
return pos;
}
private static String firstLine(String text) {
int newline = text.indexOf('\n');
return newline < 0 ? text : text.substring(0, newline);
}
/** One rendered character of a box line, and whether it was drawn inside a faint (dim) span. */
private record Glyph(char c, boolean faint) {
}
/**
* The text the box holds: its own line after the marker, with border, padding, cursor and any
* faint (placeholder-hint) text left out — only a character drawn outside a faint span is the
* operator's own typing.
*/
private static String boxContent(String boxLine) {
List<Glyph> glyphs = renderedGlyphs(boxLine.substring(markerLength(boxLine)));
int end = glyphs.size();
while (end > 0 && isBoxPadding(glyphs.get(end - 1).c())) end--;
if (end > 0 && glyphs.get(end - 1).c() == '│') end--;
StringBuilder content = new StringBuilder();
for (int i = 0; i < end; i++) {
Glyph glyph = glyphs.get(i);
if (glyph.faint() || isBoxPadding(glyph.c())) continue;
content.append(glyph.c());
}
return content.toString();
}
/** Decode {@code text} into its rendered characters, tracking the faint (SGR 2) span each sits in. */
private static List<Glyph> renderedGlyphs(String text) {
List<Glyph> glyphs = new ArrayList<>();
Matcher m = SGR.matcher(text);
boolean faint = false;
int i = 0;
while (i < text.length()) {
if (m.find(i) && m.start() == i) {
String codes = m.group(1);
if (RESET_CODES.contains(codes)) faint = false;
else if (FAINT_CODE.equals(codes)) faint = true;
i = m.end();
continue;
}
glyphs.add(new Glyph(text.charAt(i), faint));
i++;
}
return glyphs;
}
private static boolean isBoxPadding(char c) {
return Character.isWhitespace(c) || Character.isSpaceChar(c) || CURSOR_GLYPHS.indexOf(c) >= 0;
}
}
@@ -94,6 +94,17 @@ public final class Injector {
*/
private static final int READINESS_GRACE_POLLS = 240;
/**
* How many consecutive polls a message may sit queued with no delivery attempt at all before
* it is failed and the queue cleared — covers every reason the head of the queue is never
* reached, including a target that stays busy ({@code working}) or unclassifiable
* ({@code unknown}) for the whole window. Set well above an ordinary turn so a worker
* genuinely mid-task is never cut off, and below a caller's own overall timeout so a target
* that never frees up fails with this specific reason instead of riding out that longer wait
* silently.
*/
private static final int QUEUE_WAIT_GRACE_POLLS = 4800;
/**
* The single source for the injector poll cadence — how often the {@link StatusPoller} drives
* {@link #onStatus} at. {@code Fleetd} passes this to every {@link StatusPoller} it constructs,
@@ -121,6 +132,12 @@ public final class Injector {
* already uses for the same purpose.
*/
private final LongSupplier nowMillis;
/**
* Mail offered to panes that collect it themselves. Owned here because this is the single
* writer of delivery state, and the offer must be made and taken back under the same target
* monitor that guards the queue the message is still sitting on.
*/
private final PaneInbox paneInbox;
private final ConcurrentHashMap<String, Target> targets = new ConcurrentHashMap<>();
/** Delivery only; completion signalling is a no-op and every target is treated as available. */
@@ -192,6 +209,7 @@ public final class Injector {
this.ready = ready;
this.forget = forget;
this.nowMillis = nowMillis;
this.paneInbox = new PaneInbox(nowMillis);
}
public Injector(HerdrRouter router, TurnListener turnListener, Predicate<String> ready,
@@ -221,6 +239,7 @@ public final class Injector {
this.ready = ready;
this.forget = forget;
this.nowMillis = nowMillis;
this.paneInbox = new PaneInbox(nowMillis);
}
private AgentControl agentsFor(String target) {
@@ -296,13 +315,16 @@ public final class Injector {
final String text;
final TurnToken token;
final CompletableFuture<Void> delivered;
final long enqueuedAtMillis;
volatile State state = State.QUEUED; // written under the owning Target monitor
Pending(String target, String text, TurnToken token, CompletableFuture<Void> delivered) {
Pending(String target, String text, TurnToken token, CompletableFuture<Void> delivered,
long enqueuedAtMillis) {
this.target = target;
this.text = text;
this.token = token;
this.delivered = delivered;
this.enqueuedAtMillis = enqueuedAtMillis;
}
String text() {
@@ -329,10 +351,15 @@ public final class Injector {
int unknownSincePostTurn; // the same, for the post-turn housekeeping phase (fleetd #306)
int notReadySincePoll; // consecutive injectable samples a queued message waited on the readiness gate (CB-114)
long notReadySinceMillis; // wall-clock time of the FIRST non-ready sample in the current notReadySincePoll streak (fleetd #501); reset alongside it
int queueWaitSincePoll; // consecutive polls the queue has held an undelivered message with no attempt made
boolean postTurnPending; // completion observed; adapter housekeeping has not started yet
boolean awaitingPostTurnPickup;
boolean postTurnObserved;
int injectableSincePostTurnPickup;
/** The head of {@link #queue} as offered to a mod-served pane, or {@code null}. */
PaneInbox.Entry inboxOffer;
/** Whether the delivery the pickup latch is waiting on was collected rather than typed. */
boolean deliveredViaInbox;
synchronized void add(Pending p) {
queue.add(p);
@@ -349,7 +376,7 @@ public final class Injector {
*/
public Delivery enqueue(String target, String text, TurnToken token) {
CompletableFuture<Void> delivered = new CompletableFuture<>();
Pending p = new Pending(target, text, token, delivered);
Pending p = new Pending(target, text, token, delivered, nowMillis.getAsLong());
targets.compute(target, (_, existing) -> {
Target t = (existing != null) ? existing : new Target();
t.add(p); // synchronized on the Target monitor — atomic with a concurrent drop
@@ -361,7 +388,8 @@ public final class Injector {
/**
* Cancel this exact queued delivery. The target monitor serializes this operation with
* {@link #onStatus}: if delivery wins that race, this returns {@link Cancellation#DELIVERED}
* rather than claiming the message remained queued.
* rather than claiming the message remained queued. A message a mod-served pane has already
* collected answers the same way, even though no poll has recorded that delivery yet.
*/
public Cancellation cancel(Delivery delivery) {
Pending p = delivery.pending;
@@ -370,7 +398,22 @@ public final class Injector {
return cancellationOf(p);
}
synchronized (t) {
if (p.state != Pending.State.QUEUED || !t.queue.remove(p)) {
if (p.state != Pending.State.QUEUED) {
return cancellationOf(p);
}
if (t.queue.peek() == p && t.inboxOffer != null) {
// This exact entry is the one offered to a mod-served pane. The pane takes an
// offer on its own thread, so withdraw first and then read the outcome: a taken
// offer means the pane already holds this text, and the next poll records that
// delivery. Cancelling it would tell the caller nothing arrived while the pane
// acts on it.
paneInbox.withdrawAll(p.target);
if (t.inboxOffer.taken()) {
return Cancellation.DELIVERED;
}
t.inboxOffer = null;
}
if (!t.queue.remove(p)) {
return cancellationOf(p);
}
p.state = Pending.State.CANCELLED;
@@ -415,6 +458,7 @@ public final class Injector {
boolean resubmit = false;
boolean startPostTurn = false;
List<Pending> notReady = null; // queued messages failed because the worker never became ready
List<Pending> queueStalled = null; // queued messages failed because the queue never drained
synchronized (t) {
if (status == AgentStatus.WORKING) {
if (t.awaitingPostTurnPickup) {
@@ -454,10 +498,14 @@ public final class Injector {
t.injectableSincePickup = 0;
t.awaitingCompletion = false;
t.turnObserved = false;
} else {
} else if (!t.deliveredViaInbox) {
// Delivered but still idle → the worker hasn't picked it up; the submit
// keystroke likely raced the paste (esp. right as the TUI became ready).
// Re-nudge Enter (CB-113) until the worker starts (WORKING) or the grace ends.
//
// A pane that collected the message submits it itself, and nothing was
// typed into it. Pressing Enter there would submit whatever its operator
// has in the prompt box instead.
resubmit = true;
}
}
@@ -482,45 +530,77 @@ public final class Injector {
if (p != null && ready.test(target)) {
t.notReadySincePoll = 0;
t.notReadySinceMillis = 0;
// fleetd #551: poll and record BEFORE the irreversible send, not after.
// The entry comes off the queue and its state is set to ATTEMPTED here,
// unconditionally — so a Throwable escaping the send call below (caught or
// not) can never leave the entry QUEUED at the head of t.queue (the fleetd
// #546 hazard, since peek() alone would let the next onStatus round re-enter
// this block and send the same text again), and no path can write a
// confident DELIVERED or NOT_DELIVERED before we actually know which one
// happened.
t.queue.poll();
p.state = Pending.State.ATTEMPTED;
try {
agentsFor(target).send(target, p.text());
p.state = Pending.State.DELIVERED;
t.awaitingPickup = true;
t.awaitingCompletion = true;
t.turnObserved = false;
t.injectableSincePickup = 0;
sent = p;
} catch (Throwable e) {
// fleetd #551: leave p.state == ATTEMPTED (recorded above, before the
// call) rather than downgrading it to NOT_DELIVERED here — reaching this
// catch does not prove the text never reached the pane. Three of the
// four HerdrException throw sites in HerdrCodec fire only after herdr
// has already replied (so it processed the request), and the fourth (a
// transport IOException) leaves it genuinely unknown whether herdr even
// received the bytes — see #551 comment 16867. The one exception is a
// herdr `*_not_found` error: that family is already read as "definitely
// absent, not merely inconclusive" everywhere else in this codebase
// (StatusPoller, AgentControl's own retry, WorkspaceControl,
// HerdrPeerLauncher, FleetApp, ReplyPushLoop) because it means the
// target pane/agent does not exist at all, so nothing could have been
// pasted anywhere — #551 keeps the new state consistent with that
// existing vocabulary rather than inventing a second one.
if (e instanceof HerdrException he && he.code() != null
&& he.code().endsWith("_not_found")) {
p.state = Pending.State.NOT_DELIVERED;
boolean modServed = paneInbox.isModServed(target);
if (t.inboxOffer != null && !modServed) {
// The pane stopped collecting its mail, so take the offer back and
// fall through to the terminal route below. withdrawAll leaves an
// entry the pane took first alone, so the branch under it still sees
// that as the delivery it is.
paneInbox.withdrawAll(target);
if (!t.inboxOffer.taken()) {
t.inboxOffer = null;
}
}
if (t.inboxOffer == null && modServed) {
t.inboxOffer = paneInbox.offer(target, p.text());
}
if (t.inboxOffer != null) {
// The message stays at the head of the queue until the pane takes
// it: nothing has reached the pane yet, so nothing may be recorded
// as delivered and nothing may be failed.
if (t.inboxOffer.taken()) {
t.inboxOffer = null;
t.queue.poll();
p.state = Pending.State.DELIVERED;
t.awaitingPickup = true;
t.awaitingCompletion = true;
t.turnObserved = false;
t.injectableSincePickup = 0;
t.deliveredViaInbox = true;
sent = p;
}
} else {
// fleetd #551: poll and record BEFORE the irreversible send, not after.
// The entry comes off the queue and its state is set to ATTEMPTED here,
// unconditionally — so a Throwable escaping the send call below (caught or
// not) can never leave the entry QUEUED at the head of t.queue (the fleetd
// #546 hazard, since peek() alone would let the next onStatus round re-enter
// this block and send the same text again), and no path can write a
// confident DELIVERED or NOT_DELIVERED before we actually know which one
// happened.
t.queue.poll();
p.state = Pending.State.ATTEMPTED;
try {
agentsFor(target).send(target, p.text());
p.state = Pending.State.DELIVERED;
t.awaitingPickup = true;
t.awaitingCompletion = true;
t.turnObserved = false;
t.injectableSincePickup = 0;
t.deliveredViaInbox = false;
sent = p;
} catch (Throwable e) {
// fleetd #551: leave p.state == ATTEMPTED (recorded above, before the
// call) rather than downgrading it to NOT_DELIVERED here — reaching this
// catch does not prove the text never reached the pane. Three of the
// four HerdrException throw sites in HerdrCodec fire only after herdr
// has already replied (so it processed the request), and the fourth (a
// transport IOException) leaves it genuinely unknown whether herdr even
// received the bytes — see #551 comment 16867. The one exception is a
// herdr `*_not_found` error: that family is already read as "definitely
// absent, not merely inconclusive" everywhere else in this codebase
// (StatusPoller, AgentControl's own retry, WorkspaceControl,
// HerdrPeerLauncher, FleetApp, ReplyPushLoop) because it means the
// target pane/agent does not exist at all, so nothing could have been
// pasted anywhere — #551 keeps the new state consistent with that
// existing vocabulary rather than inventing a second one.
if (e instanceof HerdrException he && he.code() != null
&& he.code().endsWith("_not_found")) {
p.state = Pending.State.NOT_DELIVERED;
}
sent = p;
sendError = e;
}
sent = p;
sendError = e;
}
} else if (p != null) {
// fleetd #501: stamp the wall-clock time of the FIRST non-ready sample in
@@ -539,6 +619,8 @@ public final class Injector {
for (Pending pending : notReady) {
pending.state = Pending.State.NOT_DELIVERED;
}
paneInbox.withdrawAll(target);
t.inboxOffer = null;
// fleetd #501: t.notReadySincePoll — the loop's own counter, already in
// scope — is printed here instead of the READINESS_GRACE_POLLS constant.
// On this branch the counter has JUST reached the threshold, so the two
@@ -606,6 +688,30 @@ public final class Injector {
}
}
// A message still queued and never attempted this poll is bounded on its own, whatever
// the reason the head of the queue was never reached — a target stuck WORKING or
// UNKNOWN for the whole window hits this even though neither branch above ever looks at
// the queue. Any poll that did attempt the head (`sent != null`, success or failure
// alike) counts as progress and resets the streak, even if messages remain behind it.
if (!t.queue.isEmpty() && sent == null) {
if (++t.queueWaitSincePoll >= QUEUE_WAIT_GRACE_POLLS) {
queueStalled = new ArrayList<>(t.queue);
for (Pending pending : queueStalled) {
pending.state = Pending.State.NOT_DELIVERED;
}
paneInbox.withdrawAll(target);
t.inboxOffer = null;
log.warn("queue for {} never drained after {} polls (limit={} polls/{}s): "
+ "failing {} queued message(s) that were never attempted",
target, t.queueWaitSincePoll, QUEUE_WAIT_GRACE_POLLS,
QUEUE_WAIT_GRACE_POLLS * POLL_INTERVAL_MILLIS / 1000, queueStalled.size());
t.queue.clear();
t.queueWaitSincePoll = 0;
}
} else {
t.queueWaitSincePoll = 0;
}
// Reclaim the entry once the worker is fully quiescent (nothing queued, no pickup or
// completion awaited), so the map cannot grow without bound across short-lived workers.
if (isQuiescent(t)) {
@@ -676,6 +782,19 @@ public final class Injector {
forget.accept(target);
turnListener.onTurnFailed(target);
}
if (queueStalled != null) {
// The target is not gone — it may still be genuinely busy — so this does not call
// forget.accept: that would clear presence/readiness state for a worker that is
// simply taking a long turn. It still resolves the awaiting send's own waiter via
// onTurnFailed (mirroring notReady above), so a caller learns this specific message
// never reached the pane instead of riding out its own much longer timeout.
RuntimeException cause = new IllegalStateException(
target + " never freed up to receive this message within the queue wait grace");
for (Pending p : queueStalled) {
p.delivered().completeExceptionally(cause);
}
turnListener.onTurnFailed(target, cause.getMessage());
}
if (turnCompleted) {
if (startPostTurn) {
// fleetd #553: the listener call is wrapped so `t.postTurnPending` (set true inside
@@ -795,6 +914,24 @@ public final class Injector {
}
}
/**
* Hand {@code terminal} every message held for it and stamp it as collecting its own mail.
* While that stamp is fresh this injector offers that pane's messages instead of typing them;
* once it goes stale the pane's queued mail takes the terminal route again.
*
* <p>Returns the messages in the order they were queued, and an empty list when there are
* none — an empty collection still counts as collecting, so a pane that polls on a timer stays
* mod-served between messages.
*/
public List<String> collectInbox(String terminal) {
return paneInbox.drain(terminal);
}
/** Whether {@code terminal} has collected its mail recently enough to be offered the next one. */
public boolean isModServed(String terminal) {
return paneInbox.isModServed(terminal);
}
/**
* Targets the poller must keep sampling: those with a queued message, an awaited pickup, or an
* awaited turn completion (so the {@code working → idle} boundary is observed).
@@ -812,6 +949,23 @@ public final class Injector {
.collect(Collectors.toSet());
}
/**
* How long the oldest still-queued, never-attempted message for {@code target} has been
* waiting, or {@code null} when nothing is queued (including when the head has already been
* attempted or delivered). A caller uses this to tell a message that genuinely never reached
* the pane apart from one that was delivered and is now simply being worked on.
*/
public Long queuedWaitMillis(String target) {
Target t = targets.get(target);
if (t == null) {
return null;
}
synchronized (t) {
Pending head = t.queue.peek();
return head != null ? nowMillis.getAsLong() - head.enqueuedAtMillis : null;
}
}
/**
* Forget a target whose worker is gone, failing every still-queued message so awaiting callers
* unblock instead of hanging forever. If a message had already been <em>delivered</em> but its
@@ -831,6 +985,11 @@ public final class Injector {
p.state = Pending.State.NOT_DELIVERED;
}
t.queue.clear();
// The pane is gone, so drop its offered mail and its poll stamp together: a terminal
// id can be reused, and a stale stamp would make the next pane under it look mod-served
// before it has ever collected anything.
paneInbox.forget(target);
t.inboxOffer = null;
hadDeliveredTurn = t.awaitingCompletion;
t.awaitingCompletion = false;
t.awaitingPickup = false;
@@ -4,16 +4,17 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.Set;
/**
* Tracks which workers are <em>available</em> — their Claude has booted and connected its MCP client
* to the bridge (CB-113). This is the reliable readiness signal, unlike herdr's {@code agent_status},
* which reports {@code idle} for a worker whose Claude is still booting. Delivering into that boot
* window pastes into a not-yet-ready TUI (the text is lost) and wedges the worker's delivery state,
* so the {@link Injector} holds the first delivery until the worker is present here.
* Tracks which peers are <em>available</em> — their Claude has booted and connected its MCP
* client to the bridge. For a spawned member this is the reliable readiness signal,
* unlike herdr's {@code agent_status}, which reports {@code idle} while its Claude is still
* booting. Delivering into that boot window pastes into a not-yet-ready TUI (the text is lost)
* and wedges that member's delivery state, so the {@link Injector} holds a spawned member's
* first delivery until it is present here.
*
* <p>Populated from the MCP transport: any MCP request whose connection resolves to a worker terminal
* marks that worker present (its {@code initialize} is the first such contact). A worker that never
* mounts the bridge MCP is never marked present — its sends stay queued until they time out, which is
* correct (it could not have replied anyway).
* <p>Populated from the MCP transport, for the peers whose deliverability rests on proving a live
* MCP contact rather than on a configured registry entry. A peer that never mounts the bridge MCP
* is never marked present — its sends stay queued until they time out, which is correct (it could
* not have replied anyway).
*/
public class MemberPresence {
@@ -0,0 +1,141 @@
package dev.ltms.fleet.inject;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.LongSupplier;
/**
* Mail held for a pane that collects it itself instead of having it typed into its terminal.
*
* <p>A pane becomes <em>mod-served</em> by calling {@code fleet_inbox}: {@link #drain} stamps the
* pane as polling, and {@link #isModServed} answers {@code true} while that stamp is younger than
* {@link #MOD_SERVED_WINDOW_MILLIS}. Nothing else sets it, so a pane that has never polled is
* never mod-served and its mail takes the terminal route.
*
* <p>An offered entry is removed exactly once, by {@link #drain} or by {@link #withdrawAll}, and
* both run under the owning pane's monitor. So an entry the pane took is never also withdrawn, and
* an entry that was withdrawn can never still be collected — which is what lets the {@link
* Injector} keep one message both offered here and queued for the terminal without risking two
* deliveries of it.
*
* <p>This class holds no queue of its own beyond what is currently offered: the {@link Injector}
* keeps the message on its own queue until the pane takes it, so a pane that stops polling strands
* nothing.
*/
public class PaneInbox {
/**
* How long after a {@link #drain} a pane still counts as mod-served. It must exceed the mod's
* own poll interval by enough that a few missed polls are not read as a pane that stopped,
* while staying short enough that a pane which really stopped falls back to the terminal route
* promptly.
*/
public static final long MOD_SERVED_WINDOW_MILLIS = 15_000;
/** One message held for a pane until that pane collects it. */
public static final class Entry {
private final String text;
private boolean taken; // written under the owning pane's monitor
private Entry(String text) {
this.text = text;
}
/** The message text, as it will be handed to the pane. */
public String text() {
return text;
}
/** Whether the pane has collected this entry. Once {@code true} it never goes back. */
public synchronized boolean taken() {
return taken;
}
private synchronized void markTaken() {
taken = true;
}
}
private final LongSupplier nowMillis;
private final ConcurrentHashMap<String, Long> lastPolledAtMillis = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Deque<Entry>> offered = new ConcurrentHashMap<>();
public PaneInbox() {
this(System::currentTimeMillis);
}
public PaneInbox(LongSupplier nowMillis) {
this.nowMillis = Objects.requireNonNull(nowMillis, "nowMillis");
}
/**
* Whether {@code terminal} collected its mail within {@link #MOD_SERVED_WINDOW_MILLIS}. A
* terminal that has never collected any is never mod-served.
*/
public boolean isModServed(String terminal) {
Long at = terminal == null ? null : lastPolledAtMillis.get(terminal);
return at != null && nowMillis.getAsLong() - at <= MOD_SERVED_WINDOW_MILLIS;
}
/** Hold {@code text} for {@code terminal} to collect, and return the entry holding it. */
public Entry offer(String terminal, String text) {
Entry entry = new Entry(text);
Deque<Entry> queue = offered.computeIfAbsent(terminal, _ -> new ArrayDeque<>());
synchronized (queue) {
queue.add(entry);
}
return entry;
}
/**
* Take back every entry {@code terminal} has not collected. An entry the pane took first stays
* taken — this never un-delivers one.
*/
public void withdrawAll(String terminal) {
Deque<Entry> queue = terminal == null ? null : offered.get(terminal);
if (queue == null) {
return;
}
synchronized (queue) {
queue.removeIf(e -> !e.taken());
}
}
/**
* Collect everything held for {@code terminal}, in the order it was offered, and stamp the pane
* as polling. Each returned entry is marked taken, so the {@link Injector} can tell a message
* the pane really has from one it merely offered.
*/
public List<String> drain(String terminal) {
if (terminal == null || terminal.isBlank()) {
return List.of();
}
lastPolledAtMillis.put(terminal, nowMillis.getAsLong());
Deque<Entry> queue = offered.get(terminal);
if (queue == null) {
return List.of();
}
List<String> collected = new ArrayList<>();
synchronized (queue) {
for (Entry e : queue) {
e.markTaken();
collected.add(e.text());
}
queue.clear();
}
return collected;
}
/** Forget a pane that is gone, so neither its poll stamp nor its offered mail lingers. */
public void forget(String terminal) {
if (terminal == null) {
return;
}
lastPolledAtMillis.remove(terminal);
offered.remove(terminal);
}
}
@@ -18,6 +18,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -297,15 +298,11 @@ public final class LeadLauncher {
/**
* How many live leads exist per configured name, and which of that name's labelled tabs are
* <em>not</em> live: a running agent in a tab labelled with that lead's exact {@code tab}
* (CB-579). A member sitting in the same shared workspace is not counted as a lead because its
* tab carries a different label, not because any workspace is excluded from this count.
*
* <p>There used to be a second path here — a running agent on the terminal a
* {@code fleet.leaders.<name>.terminal} pin named, for a lead opened and pinned by hand. That
* pin is retired: {@code tab} is now the only field identity depends on, and {@link Agent}
* already carries {@link Agent#tabId()} directly, so a hand-opened lead is found the same way an
* auto-launched one is — by labelling its tab to match.
* <em>not</em> live: a running agent in a tab, in that lead's own space, carrying one of its
* {@link FleetConfig.Leader#acceptedLabels()}. A member sitting in the same shared workspace is
* not counted as a lead because its tab carries a different label, not because any workspace is
* excluded from this count. A tab matching a lead's label in a <em>different</em> space is not
* counted either — space is the uniqueness boundary between leads.
*
* <p>fleetd #359 review finding 1: a labelled tab with nothing running in it is split into
* {@code toClose} (already flagged pending-close by a previous reconcile, and still dead — two
@@ -319,12 +316,11 @@ public final class LeadLauncher {
}
private Map<String, LeadCount> countLeads(Map<String, FleetConfig.Leader> leaders) {
// A lead and the members share ONE workspace now (the operator asked for a single "session"
// with many tabs), so a workspace can no longer be excluded wholesale — the lead lives in the
// member workspace by design. The sole discriminator is the exact tab label: a lead carries
// its configured `fleet.leaders.<name>.tab` ("lead: opus"), while a member carries its
// profile's `worker: {profile} #{n}` template. These never collide, so an exact-label match
// separates them without needing to know which workspace anyone is in.
// A lead and the members share ONE workspace (the operator asked for a single "session" with
// many tabs), so a workspace can no longer be excluded wholesale — the lead lives in the
// member workspace by design. The discriminator is the tab label together with the space: a
// member's tab never carries one of a lead's accepted labels, and a lead's own label only
// counts within that lead's configured space.
Map<String, String> nameByTab = new LinkedHashMap<>();
Set<String> flaggedTabIds = new LinkedHashSet<>();
for (Workspace ws : spaces.listWorkspaces()) {
@@ -332,7 +328,7 @@ public final class LeadLauncher {
continue;
}
for (Tab tab : spaces.listTabs(ws.workspaceId())) {
String declared = leadNameOf(tab.label(), leaders);
String declared = leadNameOf(tab.label(), ws.label(), leaders);
if (declared != null && tab.tabId() != null) {
nameByTab.put(tab.tabId(), declared);
if (PendingCloseMarker.isFlagged(tab.label())) {
@@ -382,21 +378,24 @@ public final class LeadLauncher {
}
/**
* The configured lead a tab label names, or {@code null} for a label that names none.
* The configured lead a tab names, or {@code null} for a label or space that names none.
*
* <p>Matched exactly (case-insensitively) against each lead's configured {@code tab}, so an
* operator's {@code "lead: something-else"} tab is not mistaken for a configured lead. A
* trailing {@link PendingCloseMarker} is stripped first, so a tab this class flagged on a
* previous reconcile is still recognised as the same lead's tab on this one.
* <p>A match requires both: the label (case-insensitively, trailing {@link PendingCloseMarker}
* stripped) must be one of the lead's {@link FleetConfig.Leader#acceptedLabels()}, and {@code
* space} must be that lead's own {@link FleetConfig.Leader#workspace()}. The same label in a
* different space names no lead — space is the uniqueness boundary between leads.
*/
private String leadNameOf(String label, Map<String, FleetConfig.Leader> leaders) {
if (label == null) {
private String leadNameOf(String label, String space, Map<String, FleetConfig.Leader> leaders) {
if (label == null || space == null) {
return null;
}
String l = PendingCloseMarker.strip(label);
String l = PendingCloseMarker.strip(label).toLowerCase(Locale.ROOT);
for (Map.Entry<String, FleetConfig.Leader> e : leaders.entrySet()) {
String tab = e.getValue().tabLabel();
if (tab != null && l.equalsIgnoreCase(tab.strip())) {
FleetConfig.Leader lead = e.getValue();
if (lead == null || !lead.workspace().equalsIgnoreCase(space)) {
continue;
}
if (lead.acceptedLabels().contains(l)) {
return e.getKey();
}
}
@@ -5,13 +5,13 @@ import dev.ltms.fleet.herdr.PaneLocator;
/**
* Resolves <em>who is calling</em> an MCP tool from the connection alone — the anti-spoofing
* identity model of the MCP contract. It ties the connection's loopback peer PID (from the OS)
* to a herdr agent pane (from herdr), yielding the caller's worker {@code terminal_id}. A caller
* that maps to no worker pane — the primary, or an off-host client — resolves to {@code null}.
* to a herdr agent pane (from herdr), yielding that pane's {@code terminal_id}. A connection that
* maps to no pane resolves to {@code null}; this class assigns no role to either outcome — {@link
* dev.ltms.fleet.auth.CallerResolver} does that.
*
* <p>Both sources are authoritative and unforgeable: the OS reports the real connecting PID, and
* herdr owns the PID→pane mapping. A worker cannot claim to be another worker, nor the primary.
* Single-host only (the herd shares the {@code fleetd} host); the token path is the split-host
* fallback.
* herdr owns the PID→pane mapping, so a caller cannot claim to be at another pane. Single-host
* only (the herd shares the {@code fleetd} host); the token path is the split-host fallback.
*/
public final class ConnectionIdentity {
@@ -46,9 +46,10 @@ public final class ConnectionIdentity {
}
/**
* The caller resolved from the connection: its worker {@code terminal} (or {@code null} for the
* primary / an off-host client), its {@code pid} (or {@code -1} if not resolvable), and whether
* the pane scan behind {@code terminal} ran to completion ({@link #scanComplete}).
* The caller resolved from the connection: the {@code terminal} of the pane it connects from
* (or {@code null} when the connection maps to no pane), its {@code pid} (or {@code -1} if not
* resolvable), and whether the pane scan behind {@code terminal} ran to completion
* ({@link #scanComplete}).
*/
public record Caller(String terminal, long pid, boolean scanComplete) {
@@ -87,8 +88,8 @@ public final class ConnectionIdentity {
}
/**
* The calling worker's {@code terminal_id}, or {@code null} if the caller is not a known
* on-host worker (treat as the primary).
* The terminal id of the pane the caller connects from, or {@code null} if the connection
* maps to no pane.
*/
public String callerTerminal(String remoteAddr, int remotePort) {
return resolve(remoteAddr, remotePort).terminal();
@@ -1,5 +1,6 @@
package dev.ltms.fleet.mcp;
import dev.ltms.fleet.Fleetd;
import dev.ltms.fleet.auth.AuditLog;
import dev.ltms.fleet.auth.Authz;
import dev.ltms.fleet.auth.CallerResolver;
@@ -7,6 +8,7 @@ import dev.ltms.fleet.auth.Principal;
import dev.ltms.fleet.auth.Role;
import dev.ltms.fleet.guard.GuardException;
import dev.ltms.fleet.herdr.Agent;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.metrics.FleetMetrics;
import dev.ltms.fleet.metrics.Metrics;
import dev.ltms.fleet.inject.MemberPresence;
@@ -41,6 +43,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServlet;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -52,6 +55,7 @@ import java.util.concurrent.TimeUnit;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@@ -116,9 +120,10 @@ public final class FleetMcp {
private final ConnectionIdentity identity;
/**
* Kept as a field (rather than only captured by the {@code contextExtractor} closure) so
* {@link #denyFor} can read {@link CallerResolver#knownLeadOrCollaborator()} — the classifier a
* collaborator's {@code SEND} is checked against, built from the same lead and collaborator
* maps {@link #identity}-based resolution reads.
* {@link #denyFor} can read {@link CallerResolver#knownLeadOrCollaborator()} and {@link
* CallerResolver#observerSendTarget()} — the classifiers a collaborator's and an observer's
* {@code SEND} are each checked against, built from the same maps {@link #identity}-based
* resolution reads.
*/
private final CallerResolver callers;
private final Metrics metrics; // CB-502: null → auth failures not counted
@@ -302,6 +307,34 @@ public final class FleetMcp {
public static LeadConfigDirSource none() { return new LeadConfigDirSource(_ -> null, _ -> null); }
}
/**
* Pane-discovery facts for {@code fleet_list}'s {@code panes} row — every herdr tab's display
* label, the one deliverability gate the status-gated injector itself reads, whether a
* terminal is bound to a configured architect slot, and whether an observer caller may
* {@code SEND} to it.
*
* @param tabLabels tab id → its display label, read lazily (only once the row is actually
* assembled) since it costs a herdr {@code workspace.list}/{@code tab.list}
* scan; a tab herdr reports with no label maps to a {@code null} value
* @param workspaceLabels workspace id → its display label (the herdr "space" name), read lazily
* the same way as {@code tabLabels}; a workspace herdr reports with no label,
* or one the lookup cannot find, maps to a {@code null} value
* @param deliverable the same gate {@link dev.ltms.fleet.Fleetd#deliverableTo} builds for the
* injector, keyed by terminal id — never a second, separately-derived check
* @param architectSlot the same classifier {@link CallerResolver#boundToArchitectSlot} resolves
* a caller against — never a second, separately-derived check
* @param observerSendTarget the same predicate {@link CallerResolver#observerSendTarget}
* builds for the {@code SEND} gate — never a second, separately-derived
* check
*/
public record PaneSource(Supplier<Map<String, String>> tabLabels, Supplier<Map<String, String>> workspaceLabels,
Predicate<String> deliverable, Predicate<String> architectSlot, Predicate<String> observerSendTarget) {
/** Inert source — no labels, no deliverable targets, no architect slots, nothing sendable. */
public static PaneSource none() {
return new PaneSource(Map::of, Map::of, _ -> false, _ -> false, _ -> false);
}
}
/**
* fleetd #361: peer-visibility facts for {@code fleet_list}'s {@code coordinator} row — this
* daemon's own {@link LeadChannel} (for its self mailbox state and held messages) plus the
@@ -467,7 +500,10 @@ public final class FleetMcp {
recordPrimarySingleton(primaryRegistry, callerTerminal, caller);
Map<String, Object> a = req.arguments();
String target = str(a, "sessionId");
String content = str(a, "content");
// An observer's SEND reaches a pane that cannot otherwise distinguish this
// from a human paste (see attributeIfObserver); every other caller's content
// passes through unchanged.
String content = attributeIfObserver(caller, str(a, "content"));
String turnId = str(a, "turnId");
String coordId = str(a, "coordId");
if (coordId != null && !coordId.isBlank()) {
@@ -499,8 +535,9 @@ public final class FleetMcp {
? sendAsync(messages, target, content, onAccepted, workers.profiles(), caller)
: send(messages, target, content, timeoutMs(a), onAccepted, workers.profiles(), callerOwner);
};
// fleet_reply's identity is the CONNECTION, never an argument — so the authz check
// is "is this caller a worker at all", and it can only ever reply as itself.
// fleet_reply's identity is the CONNECTION, never an argument. The authz check is
// terminal ownership, not a role test: the caller may reply only for its own pane,
// which is why no role appears in the check at all.
BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> replyHandler =
(exchange, req) -> {
String self = callerTerminal(exchange);
@@ -516,6 +553,16 @@ public final class FleetMcp {
if (denied != null) return denied;
return ask(messages, self, str(req.arguments(), "question"), timeoutMs(req.arguments()));
};
// fleet_inbox: the caller collects the mail queued for its OWN pane. Identity is the
// CONNECTION, never an argument, and the authz check is terminal ownership — the same
// shape as fleet_reply above.
BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> inboxHandler =
(exchange, req) -> {
String self = callerTerminal(exchange);
McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_inbox", req.arguments()), self);
if (denied != null) return denied;
return inbox(messages, self);
};
BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> statusHandler =
(exchange, req) -> {
McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_status", req.arguments()), null);
@@ -564,13 +611,20 @@ public final class FleetMcp {
(exchange, _) -> {
McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_list", Map.of()), null);
if (denied != null) return denied;
// A Supplier: the label lookup costs a herdr scan, and must stay behind
// panesVisible so it only runs for a caller that receives the row at all.
PaneSource panes = new PaneSource(() -> identity.panes().tabLabelsByTabId(),
() -> identity.panes().workspaceLabelsByWorkspaceId(),
Fleetd.deliverableTo(presence, callers::leads, callers::collaborators),
callers::boundToArchitectSlot, callers.observerSendTarget());
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
leadSeats, leadContextGauge, leadConfigDirs, callers.leads(),
callerTerminal(exchange),
callers.collaborators(), collaboratorsVisibleTo(principal(exchange)),
new CoordinationSource(leadChannel, peers),
coordinatorVisibleTo(principal(exchange)),
leadsVisibleTo(principal(exchange)), membersVisibleTo(principal(exchange)));
leadsVisibleTo(principal(exchange)), membersVisibleTo(principal(exchange)),
panes, panesVisibleTo(principal(exchange)), principal(exchange).isObserver());
};
BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> stopHandler =
(exchange, req) -> {
@@ -614,6 +668,7 @@ public final class FleetMcp {
McpSchema.Tool fleetProfiles = profilesTool();
McpSchema.Tool fleetWhoami = whoamiTool();
McpSchema.Tool fleetHandover = handoverTool();
McpSchema.Tool fleetInbox = inboxTool();
// fleetd #469: the tool schemas above are already named from FleetTool.wireName(), but
// this is the check that a schema was not accidentally dropped, duplicated, or added
@@ -624,7 +679,7 @@ public final class FleetMcp {
Set<String> registeredToolNames = Set.of(fleetSend.name(), fleetReply.name(), fleetAsk.name(),
fleetStatus.name(), fleetPoll.name(), fleetAck.name(), fleetSpawn.name(),
fleetList.name(), fleetStop.name(), fleetProfiles.name(), fleetWhoami.name(),
fleetHandover.name());
fleetHandover.name(), fleetInbox.name());
if (!registeredToolNames.equals(FleetTool.wireNames())) {
throw new IllegalStateException("fleetd #469: registered MCP tools " + registeredToolNames
+ " do not match the canonical tool set " + FleetTool.wireNames()
@@ -646,6 +701,7 @@ public final class FleetMcp {
.toolCall(fleetProfiles, profilesHandler)
.toolCall(fleetWhoami, whoamiHandler)
.toolCall(fleetHandover, handoverHandler)
.toolCall(fleetInbox, inboxHandler)
.build();
this.metrics = metrics;
}
@@ -707,7 +763,8 @@ public final class FleetMcp {
if (!authorizationEnforced) {
return null; // AuthorizationMode.UNENFORCED: authorization not enforced (fleetd #518)
}
if (Authz.permits(caller, action, target, callers.knownLeadOrCollaborator())) {
if (Authz.permits(caller, action, target, callers.knownLeadOrCollaborator(),
callers.observerSendTarget())) {
if (action != Authz.Action.READ && action != Authz.Action.TASK_READ) {
AuditLog.allowed(caller, action, target); // reads would drown the trail
}
@@ -769,13 +826,15 @@ public final class FleetMcp {
}
/**
* Who may see {@code fleet_list}'s {@code leads} array — exactly the roles that may
* {@link Authz.Action#SEND} to a lead: the primary, an architect, and a collaborator. A
* collaborator's own {@code fleet_whoami} carries no lead address, and {@code leads} is the
* only place this tool gives one, so a collaborator needs this array to use the send it
* already holds. A worker can never {@code SEND} at all, so it still sees neither this array
* nor {@code members}; a worker's own facts come from {@code fleet_whoami} instead. Split
* out for the same reason as {@link #coordinatorVisibleTo} and
* Who may see {@code fleet_list}'s {@code leads} array — the primary, an architect, and a
* collaborator. A collaborator's own {@code fleet_whoami} carries no lead address, and this is
* the only place this tool gives one, so a collaborator needs this array to use the
* {@link Authz.Action#SEND} it already holds. An observer holds that send to a lead too, but
* learns the address from its filtered {@code panes} rows instead: a {@code leads} row carries
* a lead's name, its configured context window and its config dir, which are the fleet's own
* shape rather than an address. A worker can never {@code SEND} at all, so it still sees
* neither this array nor {@code members}; a worker's own facts come from {@code fleet_whoami}
* instead. Split out for the same reason as {@link #coordinatorVisibleTo} and
* {@link #collaboratorsVisibleTo}: the decision must be unit-testable without fabricating an
* SDK {@code McpSyncServerExchange}, and the handler must call this named predicate rather
* than inlining the check.
@@ -795,6 +854,18 @@ public final class FleetMcp {
return caller.isPrimary() || caller.isArchitect();
}
/**
* Who may see {@code fleet_list}'s {@code panes} array — every role that may
* {@link Authz.Action#SEND} to some other pane. A plain worker holds {@code READ} but never
* {@code SEND}, so it still does not see this array. An observer does hold {@code SEND}, to a
* lead or another observer pane, so it sees the array too — and it is the only place this tool
* gives it a lead's address. {@code listFleet} filters its rows to
* {@link CallerResolver#observerSendTarget} and reduces each one; see {@code paneRows}.
*/
static boolean panesVisibleTo(Principal caller) {
return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator() || caller.isObserver();
}
/**
* The terminal of the caller on this call's connection, or {@code null} when that caller carries
* no terminal, which is only the unnamed primary. A named lead, an architect and a worker each
@@ -913,6 +984,17 @@ public final class FleetMcp {
// --- tool logic (thin adapters over the services; unit-testable) ---------------------------
/**
* The text an observer's {@code SEND} actually delivers: prefixed with the sender's own
* connection-resolved terminal, which the receiving pane cannot otherwise tell apart from a
* human paste. Every other caller's content passes through unchanged. Shared with {@code
* FleetApp}'s REST entry path so both surfaces attribute identically.
*/
public static String attributeIfObserver(Principal caller, String content) {
return caller != null && caller.isObserver()
? "[fleet_send from observer " + caller.terminal() + "]\n" + content : content;
}
/**
* {@code fleet_send}: delegate {@code content} to a worker session and block for its reply.
* The configured profiles are required so a profile name can never bypass target validation.
@@ -1040,7 +1122,29 @@ public final class FleetMcp {
return targetError;
}
String ticket = messages.sendAsync(sessionId, content, onAccepted, creator);
return text("accepted — task delegated. Poll fleet_poll with ticket=" + ticket);
return text("accepted — task delegated. Poll fleet_poll with ticket=" + ticket
+ notInjectableWarning(messages, sessionId));
}
/**
* A synchronous status check at accept time: when {@code sessionId} is not currently
* idle/blocked/done, this message is queued rather than reaching the pane right away. Empty
* when the target is injectable or its status could not be read — a best-effort warning, not
* a reason to withhold the accept receipt.
*/
private static String notInjectableWarning(MessageService messages, String sessionId) {
AgentStatus status;
try {
status = messages.status(sessionId);
} catch (RuntimeException e) {
return "";
}
if (status.injectable()) {
return "";
}
return "\n\nWarning: " + sessionId + " is currently " + status.name().toLowerCase()
+ ", not idle/blocked/done — this message is queued, not yet delivered, and will "
+ "wait until the target frees up. Poll fleet_poll to see when it lands.";
}
/** A configured profile is never a send target; other unknown values may be herdr-owned panes. */
@@ -1197,6 +1301,7 @@ public final class FleetMcp {
case SEND -> sendAction(str(arguments, "coordId"), str(arguments, "turnId"));
case REPLY -> Authz.Action.REPLY;
case ASK -> Authz.Action.ASK;
case INBOX -> Authz.Action.INBOX;
case STATUS -> Authz.Action.TASK_READ;
case LIST, PROFILES, WHOAMI -> Authz.Action.READ;
case POLL -> pollAction(str(arguments, "target"), str(arguments, "coordId"));
@@ -1340,6 +1445,33 @@ public final class FleetMcp {
return text(outcome.description());
}
/**
* {@code fleet_inbox}: hand the caller the messages queued for its own pane, so it submits them
* itself instead of having them typed into its terminal. {@code callerTerminal} is resolved
* from the connection; there is deliberately no pane argument, so no caller can collect another
* pane's mail.
*
* <p>Calling this is also what marks the pane as collecting its own mail, for a window the
* injector re-checks before every delivery. A pane that stops calling it stops being served
* this way and its queued mail is typed instead, so an empty answer is a normal result that
* must still be requested on a timer.
*
* <p>Returns a JSON object with {@code count} and {@code messages} (in the order they were
* queued), so a caller can tell "no mail" apart from a failure.
*/
static McpSchema.CallToolResult inbox(MessageService messages, String callerTerminal) {
if (callerTerminal == null) {
return error("fleet_inbox could not identify the calling pane from the connection, so "
+ "there is no inbox to collect");
}
List<String> collected = messages.collectInbox(callerTerminal);
Map<String, Object> m = new LinkedHashMap<>();
m.put("sessionId", callerTerminal);
m.put("count", collected.size());
m.put("messages", collected);
return text(json(m));
}
/** {@code fleet_ack}: acknowledge (remove) a specific reply from the inbox. */
static McpSchema.CallToolResult ack(MessageService messages, String target, String msgId) {
if (isBlank(target) || isBlank(msgId)) {
@@ -1966,6 +2098,25 @@ public final class FleetMcp {
Map.of(), false, coordination, callerIsPrimary, leadsVisible, membersVisible);
}
/**
* As below, with no pane discovery — {@code panes} is {@link PaneSource#none()} and
* {@code panesVisible} is {@code false}.
*/
static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages,
CapacitySource capacity, HealthCoverageSource healthCoverage,
LoopHealthSource loopHealth,
QuarantineSource quarantine, OutageSource outage,
LeadSeatSource leadSeats, LeadContextGauge contextGauge,
LeadConfigDirSource leadConfigDirs,
Map<String, String> leads, String selfTerm,
Map<String, String> collaborators, boolean collaboratorsVisible,
CoordinationSource coordination, boolean callerIsPrimary,
boolean leadsVisible, boolean membersVisible) {
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
leadSeats, contextGauge, leadConfigDirs, leads, selfTerm, collaborators, collaboratorsVisible,
coordination, callerIsPrimary, leadsVisible, membersVisible, PaneSource.none(), false);
}
/**
* The canonical implementation. {@code contextGauge} is the "lead context gauge" (see
* {@link LeadContextGauge}) — every wrapper overload above passes a freshly constructed one,
@@ -1984,6 +2135,11 @@ public final class FleetMcp {
* {@link #leadsVisibleTo})
* @param membersVisible whether this caller may see the {@code members} array (see
* {@link #membersVisibleTo})
* @param panes pane-discovery facts — labels and the deliverable gate for the
* {@code panes} row; {@link PaneSource#none()} for a caller that
* does not want the row
* @param panesVisible whether this caller may see the {@code panes} array (see
* {@link #panesVisibleTo})
*/
static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages,
CapacitySource capacity, HealthCoverageSource healthCoverage,
@@ -1994,11 +2150,38 @@ public final class FleetMcp {
Map<String, String> leads, String selfTerm,
Map<String, String> collaborators, boolean collaboratorsVisible,
CoordinationSource coordination, boolean callerIsPrimary,
boolean leadsVisible, boolean membersVisible) {
boolean leadsVisible, boolean membersVisible,
PaneSource panes, boolean panesVisible) {
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
leadSeats, contextGauge, leadConfigDirs, leads, selfTerm, collaborators, collaboratorsVisible,
coordination, callerIsPrimary, leadsVisible, membersVisible, panes, panesVisible, false);
}
/**
* As above, plus: an observer sees the {@code panes} array too, but filtered to
* {@link CallerResolver#observerSendTarget} and each row reduced to the five fields an
* observer may learn — see {@code paneRows}/{@code paneRow}.
*
* @param callerIsObserver whether the {@code fleet_list} caller is an observer; every wrapper
* overload above passes {@code false}, so a test that wants the
* filtered, reduced view must call this overload with an explicit
* {@code true}
*/
static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages,
CapacitySource capacity, HealthCoverageSource healthCoverage,
LoopHealthSource loopHealth,
QuarantineSource quarantine, OutageSource outage,
LeadSeatSource leadSeats, LeadContextGauge contextGauge,
LeadConfigDirSource leadConfigDirs,
Map<String, String> leads, String selfTerm,
Map<String, String> collaborators, boolean collaboratorsVisible,
CoordinationSource coordination, boolean callerIsPrimary,
boolean leadsVisible, boolean membersVisible,
PaneSource panes, boolean panesVisible, boolean callerIsObserver) {
try {
// Neither row's assembly (leadView/memberCapacityView probing herdr for live status)
// runs unless at least one of them needs the live-agent lookup backing it.
Map<String, Agent> live = (leadsVisible || membersVisible)
Map<String, Agent> live = (leadsVisible || membersVisible || panesVisible)
? workers.list().stream()
.map(Agent.class::cast)
.filter(a -> a.terminalId() != null)
@@ -2042,6 +2225,10 @@ public final class FleetMcp {
.map(e -> collaboratorRow(e.getKey(), e.getValue()))
.toList());
}
// gate BEFORE assembling the row, so the key is absent rather than present-and-empty.
if (panesVisible) {
result.put("panes", paneRows(live, roster, leads, collaborators, panes, callerIsObserver));
}
// fleetd #439: coordinator/coordinatorView is lead-to-lead coordination state and must
// never reach a worker or an architect -- gate BEFORE assembling it, not after, so the
// key is absent rather than present-and-empty.
@@ -2367,6 +2554,117 @@ public final class FleetMcp {
return m;
}
/**
* {@code panes.tabLabels()}'s herdr scan, or an empty map on a {@code HerdrException} — a
* missing label must not cost the {@code leads}/{@code members}/{@code capacity}/
* {@code coordinator} rows that share {@code listFleet}'s own {@code catch}.
*/
private static Map<String, String> tabLabelsOrEmpty(PaneSource panes) {
try {
return panes.tabLabels().get();
} catch (HerdrException e) {
return Map.of();
}
}
/** As {@link #tabLabelsOrEmpty}, for {@code panes.workspaceLabels()}. */
private static Map<String, String> workspaceLabelsOrEmpty(PaneSource panes) {
try {
return panes.workspaceLabels().get();
} catch (HerdrException e) {
return Map.of();
}
}
/**
* One row per herdr-tracked agent pane, sorted by terminal id for a stable order. {@code live}
* is the same terminal-keyed {@link Agent} map {@code leadView}/{@code memberCapacityView}
* already read, so a pane neither configured as a lead nor spawned as a member — a hand-opened
* tab — still gets a row here.
*
* <p>For an observer caller ({@code observerView}), the rows are filtered to
* {@link PaneSource#observerSendTarget} before being built, and each row is reduced — see
* {@code paneRow}. A lead's pane survives that filter, so it is where an observer reads a
* lead's {@code sessionId}.
*/
private static List<Map<String, Object>> paneRows(Map<String, Agent> live, List<MemberSession> roster,
Map<String, String> leads, Map<String, String> collaborators, PaneSource panes,
boolean observerView) {
final Map<String, String> tabLabels = tabLabelsOrEmpty(panes);
final Map<String, String> workspaceLabels = workspaceLabelsOrEmpty(panes);
Map<String, MemberSession> byTerminal = roster.stream()
.filter(s -> s.terminalId() != null)
.collect(Collectors.toMap(MemberSession::terminalId, Function.identity(), (_, b) -> b));
return live.values().stream()
.filter(a -> !observerView || panes.observerSendTarget().test(a.terminalId()))
.sorted(Comparator.comparing(Agent::terminalId))
.map(a -> paneRow(a, byTerminal.get(a.terminalId()), leads, collaborators, tabLabels,
workspaceLabels, panes, observerView))
.toList();
}
/**
* @param session the roster entry for this pane's terminal, or {@code null} for a pane the
* daemon never spawned as a member (a hand-opened tab, or a configured lead)
* @param tabLabels tab id → its herdr display label; a tab absent here, or carrying a
* {@code null} label itself, projects as a {@code null} "label"
* @param workspaceLabels workspace id → its herdr display label (the space name); a workspace
* absent here, or carrying a {@code null} label itself, projects as a
* {@code null} "workspaceLabel"
* @param observerView an observer's row carries only {@code sessionId}, {@code label},
* {@code status}, {@code role}, {@code deliverable} — never {@code paneId}
* (the {@code fleet_stop} handle), {@code workspaceId},
* {@code workspaceLabel}, {@code tabId}, {@code agentType}, or {@code cwd}
* (a member's worktree path is the lead's business)
*/
private static Map<String, Object> paneRow(Agent a, MemberSession session, Map<String, String> leads,
Map<String, String> collaborators, Map<String, String> tabLabels,
Map<String, String> workspaceLabels, PaneSource panes, boolean observerView) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("sessionId", a.terminalId());
if (!observerView) {
m.put("paneId", a.paneId());
m.put("workspaceId", a.workspaceId());
m.put("workspaceLabel", a.workspaceId() == null ? null : workspaceLabels.get(a.workspaceId()));
m.put("tabId", a.tabId());
}
m.put("label", a.tabId() == null ? null : tabLabels.get(a.tabId()));
if (!observerView) {
m.put("agentType", a.agentType());
}
m.put("status", a.status() == null ? "unknown" : a.status().name().toLowerCase());
m.put("role", paneRole(a.terminalId(), session, leads, collaborators, panes));
m.put("deliverable", panes.deliverable().test(a.terminalId()));
if (!observerView && session != null && session.cwd() != null) {
m.put("cwd", session.cwd());
}
return m;
}
/**
* The role this pane resolves as: a spawned member's own {@link MemberRole}, else "lead" for a
* configured but currently-unoccupied lead pane, else "architect" for a pane bound to a
* configured architect slot with no live member session, else "collaborator" for a configured
* but currently-unoccupied collaborator tab, else "observer" for a pane this daemon neither
* spawned nor configured.
*/
private static String paneRole(String terminal, MemberSession session, Map<String, String> leads,
Map<String, String> collaborators, PaneSource panes) {
if (session != null) {
return session.role().wireName();
}
if (leads.containsKey(terminal)) {
return "lead";
}
if (panes.architectSlot().test(terminal)) {
return "architect";
}
if (collaborators.containsKey(terminal)) {
return "collaborator";
}
return "observer";
}
/**
* Reads the lead context gauge for one lead. {@code configDir} is this lead's configured
* {@code CLAUDE_CONFIG_DIR} override (see {@link LeadConfigDirSource}), derived from
@@ -2563,17 +2861,20 @@ public final class FleetMcp {
return tool(FleetTool.LIST.wireName(),
"List the whole fleet the bridge tracks, in two parts. 'members' is visible to "
+ "the primary and an architect only. 'leads' is visible to those two AND a "
+ "collaborator — exactly the roles that may fleet_send to a lead, so a "
+ "collaborator can learn a lead's sessionId before using the send it already "
+ "holds. A worker holds READ to call this tool at all, but gets neither "
+ "collaborator, so a collaborator can learn a lead's sessionId before using "
+ "the send it already holds. An observer may fleet_send to a lead too, but "
+ "reads that sessionId from its own 'panes' rows instead: a 'panes' row for "
+ "an observer is filtered to the panes it may send to — a lead's pane and "
+ "another observer's — and reduced to sessionId, label, status, role and "
+ "deliverable. A worker holds READ to call this tool at all, but gets neither "
+ "array, never an empty one; a worker reads its own session, "
+ "profile, state, worktree, branch and owner from fleet_whoami instead. "
+ "'leads' are your PEERS — other "
+ "orchestrators, each with its sessionId (the address to fleet_send to), "
+ "name, live status, and 'self': true on your own row; this is how you "
+ "discover a peer lead without being told its address. 'members' are the "
+ "sessions delegated to — each with sessionId, paneId, role (architect/dev/"
+ "reviewer), profile (the backend it runs on), state, optional "
+ "sessions delegated to — each with sessionId, paneId, role (" + MemberRole.wireNames()
+ "), profile (the backend it runs on), state, optional "
+ "worktree/branch/owner/agentSessionId, and live herdr status. agentSessionId, "
+ "when present, is the id to pass as fleet_spawn's resumeSessionId to relaunch "
+ "onto that same conversation. It is ABSENT — not a guess — for a member fleetd "
@@ -2651,6 +2952,18 @@ public final class FleetMcp {
objectSchema(Map.of(), List.of()));
}
private static McpSchema.Tool inboxTool() {
return tool(FleetTool.INBOX.wireName(),
"Collect the messages the fleet has queued for YOUR OWN pane, then act on each one. "
+ "Takes no arguments: the pane is resolved from your connection, so you can "
+ "never read another session's mail. Any role may call it for itself. Call it "
+ "on a timer — each call is also what tells the daemon you collect your own "
+ "mail, so it offers your next message here instead of typing it into your "
+ "terminal; stop calling it and your mail is typed instead. An empty "
+ "'messages' array is the normal answer when nothing is waiting.",
objectSchema(Map.of(), List.of()));
}
private static McpSchema.Tool handoverTool() {
return tool(FleetTool.HANDOVER.wireName(),
"Replace your OWN lead session once its context is full: write a handover file, "
@@ -44,7 +44,8 @@ public enum FleetTool {
STOP("fleet_stop"),
PROFILES("fleet_profiles"),
WHOAMI("fleet_whoami"),
HANDOVER("fleet_handover");
HANDOVER("fleet_handover"),
INBOX("fleet_inbox");
private final String wireName;
@@ -2,6 +2,7 @@ package dev.ltms.fleet.msg;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.PromptBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,7 +24,8 @@ import java.util.function.Supplier;
* <p><strong>Status-gated, exactly like {@link ReplyPushLoop}.</strong> A pane may only be injected
* into at a turn boundary ({@link AgentStatus#injectable()} — idle, blocked or done); pasting into
* a live turn corrupts it. So a tick that finds the lead busy simply does nothing and comes back
* later.
* later. The same holds for a lead whose prompt box holds unsubmitted text ({@link PromptBox}) —
* delivering there would submit the operator's half-typed line along with the message.
*
* <p><strong>Ack only after delivery.</strong> A message is acked — removed from the broker — only
* once {@link AgentControl#send} has actually put it in the pane. Anything not delivered (no lead
@@ -52,6 +54,7 @@ public final class LeadCoordLoop {
private final LeadChannel channel;
private final AgentControl agents;
private final PromptBox promptBox;
private final Supplier<Map<String, String>> leads;
private final ScheduledExecutorService scheduler;
private final long intervalMs;
@@ -73,6 +76,7 @@ public final class LeadCoordLoop {
ScheduledExecutorService scheduler, long intervalMs) {
this.channel = channel;
this.agents = agents;
this.promptBox = new PromptBox(agents);
this.leads = leads;
this.scheduler = scheduler;
this.intervalMs = intervalMs;
@@ -149,6 +153,11 @@ public final class LeadCoordLoop {
lead, status, held.size());
return;
}
if (!promptBox.clearToSubmit(lead)) {
log.debug("lead coordination: lead {} has unsubmitted text in its prompt box, holding {} message(s)",
lead, held.size());
return;
}
try {
agents.send(lead, DELIVERY_FORMAT.formatted(msg.from(), msg.content()));
} catch (RuntimeException e) {
@@ -2,6 +2,7 @@ package dev.ltms.fleet.msg;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.PromptBox;
import dev.ltms.fleet.lead.LeadContextGauge;
import dev.ltms.fleet.mcp.PrimaryRegistry;
import dev.ltms.fleet.metrics.FleetMetrics;
@@ -33,7 +34,7 @@ import java.util.function.Supplier;
* no such block it is never constructed, so upgrading the daemon cannot silently acquire a behaviour
* that spends the operator's model subscription on its own initiative (constraint 1).
*
* <p>Four invariants keep it from becoming a runaway subscription burner:
* <p>Five invariants keep it from becoming a runaway subscription burner:
* <ol>
* <li><b>Status-gated</b> — a {@code WORKING} lead is making progress and is never touched; only an
* injectable (idle/done/blocked) lead is even considered (constraint 2).</li>
@@ -45,6 +46,8 @@ import java.util.function.Supplier;
* <li><b>Never races {@link ReplyPushLoop}</b> — while that loop is actively nudging any target this
* loop stands down, so two competing injections never start two turns in the same pane
* (constraint 6).</li>
* <li><b>Never submits the operator's draft</b> — a nudge is held while the lead's prompt box holds
* unsubmitted text ({@link PromptBox}), because the delivery pastes and submits in one call.</li>
* </ol>
*
* <p><b>fleetd #609 — context-high notice.</b> Optionally ({@code contextHighNudge}, opt-in like the
@@ -65,6 +68,7 @@ public final class LeadHeartbeatLoop {
private final PrimaryRegistry primaryRegistry;
private final AgentControl agents;
private final PromptBox promptBox;
private final ReplyInbox inbox;
private final Supplier<List<MemberSession>> roster;
private final ReplyPushLoop pushLoop;
@@ -135,6 +139,7 @@ public final class LeadHeartbeatLoop {
boolean requireOperatorConfirm) {
this.primaryRegistry = primaryRegistry;
this.agents = agents;
this.promptBox = new PromptBox(agents);
this.inbox = inbox;
this.roster = roster;
this.pushLoop = pushLoop;
@@ -187,7 +192,9 @@ public final class LeadHeartbeatLoop {
/** Idle past the quiet period with nothing pending and the cap exhausted — stop until new state appears. */
QUIET_DONE,
/** {@link ReplyPushLoop} is actively nudging — stand aside rather than start a competing turn. */
STAND_DOWN
STAND_DOWN,
/** The lead's prompt box holds unsubmitted text — hold the nudge rather than submit that text. */
DRAFT_HELD
}
/**
@@ -330,6 +337,7 @@ public final class LeadHeartbeatLoop {
idleSinceNanos == NOT_IDLE ? null : idleSinceNanos,
quietCount, status, pushLoop.isActive(), leadKnown, fleet,
reading.state(), contextNotified);
d = holdIfOperatorIsTyping(d);
applyDecision(d);
switch (d.action()) {
case INJECT -> injectNudge(d, fleet, reading);
@@ -337,11 +345,33 @@ public final class LeadHeartbeatLoop {
countNudge("exhausted");
contextNotified = d.contextNotified();
}
case WAIT_IDLE, LEAD_BUSY, STAND_DOWN -> contextNotified = d.contextNotified();
case WAIT_IDLE, LEAD_BUSY, STAND_DOWN, DRAFT_HELD -> contextNotified = d.contextNotified();
}
scheduleNext();
}
/**
* Turn a decision to inject into {@link Action#DRAFT_HELD} when the lead's prompt box holds text
* the operator has not submitted. The pane read happens only for a decision that would otherwise
* send, so a busy or debouncing lead costs no extra herdr call.
*
* <p>The held decision carries this tick's idle window but the <em>pre-tick</em> quiet count and
* context latch: nothing reached the pane, so neither the quiet budget nor the one context notice
* per stretch may be spent on it.
*/
private Decision holdIfOperatorIsTyping(Decision d) {
if (d.action() != Action.INJECT) {
return d;
}
var lead = primaryRegistry.currentPrimaryTerminal();
if (lead.isEmpty() || promptBox.clearToSubmit(lead.get())) {
return d;
}
log.debug("idle-heartbeat: lead {} has unsubmitted text in its prompt box, holding the nudge",
lead.get());
return new Decision(Action.DRAFT_HELD, d.idleSinceNanos(), quietCount, contextNotified);
}
/**
* Persist the idle/quiet state a decision returned, so the next tick starts from it.
*
@@ -514,6 +514,20 @@ public final class MessageService {
return false;
}
/**
* Hand {@code session} every message queued for it that it has not collected yet, and record
* that it collects its own mail. While that record is fresh, delivery to that session is
* offered for collection instead of typed into its terminal; once it goes stale, the terminal
* route takes over again with nothing lost.
*
* <p>The messages are returned in the order they were queued, and are removed by this call.
* An empty list is an ordinary answer: a session polling on a timer keeps itself collecting
* between messages.
*/
public List<String> collectInbox(String session) {
return injector.collectInbox(session);
}
/**
* Route a worker's explicit {@code fleet_reply}: resolve an open send, complete an async ticket
* still parked waiting on this exact turn's answer, or — only once neither applies — queue it in
@@ -1426,7 +1440,7 @@ public final class MessageService {
return new TaskView(ticket, Phase.ASKING, question.text(), null,
"worker is waiting for your answer", question.turnId());
}
return new TaskView(ticket, Phase.PENDING, null, null, "worker " + liveStatus(task.target), null);
return new TaskView(ticket, Phase.PENDING, null, null, pendingDetail(task.target), null);
}
// CB-588: the ticket is terminal and being handed to the caller right here — tell the push
// loop it is collected so a later tick's nudge never names a ticket the lead already has.
@@ -1499,6 +1513,21 @@ public final class MessageService {
return task != null && task.completedNanos != null;
}
/**
* Detail text for a {@link Phase#PENDING} poll of a plain (non-asking) delegation.
* Distinguishes a message still sitting in the injector's queue, never delivered, from one
* that already reached the pane and is simply being worked on — so a caller cannot read
* "worker working" as "received" when it was not.
*/
private String pendingDetail(String target) {
Long queuedMillis = injector.queuedWaitMillis(target);
if (queuedMillis != null) {
return "queued, not yet delivered (target is " + liveStatus(target) + "; queued "
+ (queuedMillis / 1000) + "s)";
}
return "worker " + liveStatus(target);
}
/** Best-effort live worker status for a pending poll; never throws (a lookup error is just noise). */
private String liveStatus(String target) {
try {
@@ -3,6 +3,7 @@ package dev.ltms.fleet.msg;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.HerdrException;
import dev.ltms.fleet.herdr.PromptBox;
import dev.ltms.fleet.mcp.PrimaryRegistry;
import dev.ltms.fleet.metrics.FleetMetrics;
import dev.ltms.fleet.metrics.Metrics;
@@ -50,6 +51,11 @@ import java.util.stream.Collectors;
* exhausting its cap does not stop nudges about the others (post-CB-590 regression fix; see
* {@link #decide}) — whichever the durable inbox / pending set doesn't already answer via
* {@code STOP}.
*
* <p>A lead that is injectable is nudged only when its prompt box is also empty
* ({@link PromptBox}): the delivery pastes and submits in one call, so a nudge into a box holding
* the operator's half-typed line would submit that line too. A nudge held for that reason waits for
* the next tick like any other, and the pending work is re-read then.
*/
public final class ReplyPushLoop {
@@ -81,6 +87,7 @@ public final class ReplyPushLoop {
private final PrimaryRegistry primaryRegistry;
private final AgentControl agents;
private final PromptBox promptBox;
private final ReplyInbox inbox;
private final ScheduledExecutorService scheduler;
private final int maxReminders;
@@ -125,6 +132,7 @@ public final class ReplyPushLoop {
int maxReminders, long backoffMs, Metrics metrics) {
this.primaryRegistry = primaryRegistry;
this.agents = agents;
this.promptBox = new PromptBox(agents);
this.inbox = inbox;
this.scheduler = scheduler;
this.maxReminders = maxReminders;
@@ -398,11 +406,15 @@ public final class ReplyPushLoop {
log.debug("push: status check failed for lead {}, will retry", lead, e);
return Action.WAIT_BUSY;
}
if (status.injectable()) {
return Action.INJECT;
if (!status.injectable()) {
log.debug("push: lead {} is {} (not injectable), waiting", lead, status);
return Action.WAIT_BUSY;
}
log.debug("push: lead {} is {} (not injectable), waiting", lead, status);
return Action.WAIT_BUSY;
if (!promptBox.clearToSubmit(lead)) {
log.debug("push: lead {} has unsubmitted text in its prompt box, waiting", lead);
return Action.WAIT_BUSY;
}
return Action.INJECT;
}
/**
@@ -1,6 +1,8 @@
package dev.ltms.fleet.peer;
import java.util.Locale;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* What a member is <em>for</em> — the contract it runs under.
@@ -100,6 +102,11 @@ public enum MemberRole {
return null;
}
/** The wire name of every role, joined with {@code ", "} in declaration order. */
public static String wireNames() {
return Stream.of(values()).map(MemberRole::wireName).collect(Collectors.joining(", "));
}
/**
* Parse a config/wire spelling, case-insensitively.
*
@@ -118,14 +125,7 @@ public enum MemberRole {
}
}
}
StringBuilder valid = new StringBuilder();
for (MemberRole r : values()) {
if (!valid.isEmpty()) {
valid.append(", ");
}
valid.append(r.wireName());
}
throw new IllegalArgumentException(
"unknown member role '" + s + "'; valid roles are: " + valid);
"unknown member role '" + s + "'; valid roles are: " + wireNames());
}
}
@@ -13,6 +13,7 @@ import dev.ltms.fleet.mcp.FleetMcp;
import dev.ltms.fleet.herdr.Agent;
import dev.ltms.fleet.herdr.HerdrClient;
import dev.ltms.fleet.herdr.HerdrException;
import dev.ltms.fleet.herdr.PaneLocator;
import dev.ltms.fleet.inject.MemberPresence;
import dev.ltms.fleet.member.MemberCredentialPolicyView;
import dev.ltms.fleet.peer.PeerUnreachableException;
@@ -281,6 +282,17 @@ public final class FleetApp {
return Authz.permits(caller, action, target, knownLeadOrCollaborator);
}
/**
* As {@link #permitsFor(Principal, Authz.Action, String, Predicate)}, also threading the
* classifier an observer's {@code SEND} is checked against; pass {@link #auth}'s own
* {@code observerSendTarget()} to exercise the real production gate, as {@link #allow} does.
*/
static boolean permitsFor(Principal caller, Authz.Action action, String target,
Predicate<String> knownLeadOrCollaborator,
Predicate<String> observerSendTarget) {
return Authz.permits(caller, action, target, knownLeadOrCollaborator, observerSendTarget);
}
/**
* Gate a handler on the CB-505 authorization table. Returns {@code true} when the request may
* proceed; otherwise writes the error response and returns {@code false}.
@@ -294,7 +306,7 @@ public final class FleetApp {
return true; // legacy: authorization not enforced
}
Principal caller = ctx.attribute(CALLER);
if (permitsFor(caller, action, target, auth.knownLeadOrCollaborator())) {
if (permitsFor(caller, action, target, auth.knownLeadOrCollaborator(), auth.observerSendTarget())) {
if (action != Authz.Action.READ && action != Authz.Action.METRICS
&& action != Authz.Action.TASK_READ) {
AuditLog.allowed(caller, action, target); // reads would drown the trail
@@ -436,14 +448,27 @@ public final class FleetApp {
}
}
/**
* Tab id → its herdr display label, or an empty map on a {@code workspace.list}/{@code
* tab.list} failure — a missing label must not cost the agent roster.
*/
private Map<String, String> tabLabelsOrEmpty() {
try {
return new PaneLocator(herdr, memberHerdr).tabLabelsByTabId();
} catch (HerdrException e) {
return Map.of();
}
}
/** Discovery: every agent herdr tracks, keyed by its Claude session UUID. */
private void agents(Context ctx) {
if (!allow(ctx, routeAction("GET /agents"), null)) {
return;
}
final Map<String, String> tabLabels = tabLabelsOrEmpty();
try {
ctx.status(200).json(Map.of("agents",
workers.list().stream().map(Agent.class::cast).map(FleetApp::view).toList()));
workers.list().stream().map(Agent.class::cast).map(a -> view(a, tabLabels)).toList()));
} catch (HerdrException e) {
// fleetd #297: workers.list() reaches herdr — a transport failure must land in the same
// {error, detail} envelope every other failure path here uses, not escape as a bare
@@ -686,6 +711,10 @@ public final class FleetApp {
ctx.status(400).json(Map.of("error", "bad_request", "detail", "content is required"));
return;
}
// An observer's SEND reaches a pane that cannot otherwise distinguish this from a human
// paste (see FleetMcp#attributeIfObserver, the same rule on the MCP entry path); every
// other caller's content passes through unchanged.
content = FleetMcp.attributeIfObserver(caller, content);
timeout = Math.clamp(timeout, 1, MAX_MESSAGE_TIMEOUT_MS);
// Answering a worker's fleet_ask (CB-205): always blocks, and derives the worker from turnId.
@@ -935,13 +964,19 @@ public final class FleetApp {
}
}
/** Stable JSON projection of an agent (null-safe for the start-time shape). */
private static Map<String, Object> view(Agent a) {
/**
* Stable JSON projection of an agent (null-safe for the start-time shape).
*
* @param tabLabels tab id → its herdr display label; a tab absent from this map, or carrying
* a {@code null} label itself, projects as a {@code null} "label"
*/
private static Map<String, Object> view(Agent a, Map<String, String> tabLabels) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("terminalId", a.terminalId());
m.put("paneId", a.paneId());
m.put("workspaceId", a.workspaceId());
m.put("tabId", a.tabId());
m.put("label", tabLabels.get(a.tabId()));
m.put("sessionId", a.sessionId());
m.put("agentType", a.agentType());
m.put("status", a.status().name().toLowerCase());
@@ -125,6 +125,7 @@ class FleetdAssemblyTurnRegistrarBehaviouralTest {
primary:
tab: "lead: primary"
profile: sonnet
workspace: "ltms"
profiles:
sonnet:
subscription: true
@@ -91,6 +91,11 @@ class FleetdBackendErrorSinkTest {
return MAPPER.createObjectNode().set("agent", MAPPER.createObjectNode()
.put("terminal_id", "term_primary").put("agent_status", "idle"));
}
if ("agent.read".equals(method)) {
// The lead-nudge paths read the input box before pasting into it.
return MAPPER.createObjectNode().set("read",
MAPPER.createObjectNode().put("text", FakeHerdr.IDLE_PROMPT_CARET));
}
if ("agent.prompt".equals(method)) {
prompts.add(params);
sendLatch.countDown();
@@ -171,6 +171,7 @@ class FleetdLeadContextSourceWindowAssemblyTest {
%s:
tab: "%s"
profile: %s
workspace: "ltms"
profiles:
%s:
subscription: true
@@ -172,6 +172,7 @@ class FleetdLeadRolloverAssemblyTest {
opus:
tab: "lead: opus"
cwd: "%s"
workspace: "ltms"
leadRollover:
handoverPath: handover.md
requireOperatorConfirm: false
@@ -203,6 +204,7 @@ class FleetdLeadRolloverAssemblyTest {
tab: "lead: opus"
cwd: "%s"
profile: opus
workspace: "ltms"
profiles:
opus:
subscription: true
@@ -143,6 +143,7 @@ class FleetdLeadSeatAssemblyTest {
opus:
tab: "lead: opus"
profile: sonnet
workspace: "ltms"
profiles:
sonnet:
subscription: true
@@ -1,96 +1,174 @@
package dev.ltms.fleet;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.Dependency;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClass.Predicates;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.library.dependencies.SliceRule;
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.fail;
/**
* fleetd #131 (CB-627): enforce package boundaries with an ArchUnit test instead of a
* Maven module split.
* Enforces package boundaries between the top-level {@code dev.ltms.fleet.*} packages.
*
* <p>This test fails the build the moment a NEW cycle appears between the top-level
* {@code dev.ltms.fleet.*} packages. Today's cycles are recorded below as explicit,
* narrow exceptions: each one ignores dependencies between exactly the two named
* packages, in both directions, and nothing else. A cycle through any other pair of
* packages -- or a brand new pair -- still fails this test.
* <p>{@link #BASELINE_EDGES} names the exact {@code origin class -> target class}
* dependencies allowed to cross a top-level package boundary. Any dependency between two
* top-level packages that is not in that set fails this test, including a brand new
* dependency between a pair of packages that already has other baselined edges. A baseline
* entry whose dependency no longer exists in the code also fails this test, so the baseline
* always names exactly today's exceptions and nothing more.
*
* <p><b>Main code only.</b> The import excludes test classes
* ({@link ImportOption.Predefined#DO_NOT_INCLUDE_TESTS}). Test code legitimately wires
* across many packages for setup and mocking; that is not part of the shipped
* architecture this rule protects. Verified: importing test classes too pulls in a much
* larger, noisier cycle set -- {@code herdr}, {@code member}, {@code peer}, {@code
* config}, {@code guard} and {@code placement} all show up in cycles that disappear the
* moment test classes are excluded. Scanning off the classpath via {@code
* importPackages(...)} (not a hardcoded {@code target/classes} path) also keeps this
* test correct regardless of the working directory the build is invoked from.
*
* <p><b>No package moves here</b> -- ticket #131 is explicit that removing a cycle is
* its own, later PR. See the comment on each exception below for which ticket step
* removes it.
* ({@link ImportOption.Predefined#DO_NOT_INCLUDE_TESTS}). Scanning off the classpath via
* {@code importPackages(...)} keeps this test correct regardless of the working directory
* the build is invoked from.
*/
class PackageCyclesTest {
/**
* Exact {@code "origin -> target"} class dependencies allowed to cross a top-level
* package boundary. Each entry is one directed edge between two specific classes; a
* two-way relationship between a pair of packages is listed as two separate entries,
* one per direction.
*/
private static final Set<String> BASELINE_EDGES = Set.of(
"dev.ltms.fleet.auth.CallerResolver -> dev.ltms.fleet.mcp.ConnectionIdentity",
"dev.ltms.fleet.auth.CallerResolver -> dev.ltms.fleet.mcp.ConnectionIdentity$Caller",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.auth.AuditLog",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.auth.Authz",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.auth.Authz$Action",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.auth.CallerResolver",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.auth.Principal",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.auth.Role",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.LeadChannel",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.LeadChannel$MailboxState",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.LeadMessage",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$AskOutcome",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$AskResult",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$Outcome",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$Outstanding",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$PendingAsk",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$Phase",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$Reply",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$ReplyOutcome",
"dev.ltms.fleet.mcp.FleetMcp -> dev.ltms.fleet.msg.MessageService$TaskView",
"dev.ltms.fleet.mcp.FleetMcp$1 -> dev.ltms.fleet.msg.MessageService$AskOutcome",
"dev.ltms.fleet.mcp.FleetMcp$1 -> dev.ltms.fleet.msg.MessageService$Outcome",
"dev.ltms.fleet.mcp.FleetMcp$1 -> dev.ltms.fleet.msg.MessageService$Phase",
"dev.ltms.fleet.mcp.FleetMcp$CoordinationSource -> dev.ltms.fleet.msg.LeadChannel",
"dev.ltms.fleet.msg.LeadHeartbeatLoop -> dev.ltms.fleet.mcp.PrimaryRegistry",
"dev.ltms.fleet.msg.ReplyPushLoop -> dev.ltms.fleet.mcp.PrimaryRegistry",
"dev.ltms.fleet.inject.CompletionResolver -> dev.ltms.fleet.msg.Rendezvous",
"dev.ltms.fleet.inject.CompletionResolver -> dev.ltms.fleet.msg.Rendezvous$Resolution",
"dev.ltms.fleet.inject.CompletionResolver -> dev.ltms.fleet.msg.TurnToken",
"dev.ltms.fleet.inject.CompletionResolver$InFlight -> dev.ltms.fleet.msg.Rendezvous$Resolution",
"dev.ltms.fleet.inject.Injector -> dev.ltms.fleet.msg.TurnToken",
"dev.ltms.fleet.inject.Injector$Pending -> dev.ltms.fleet.msg.TurnToken",
"dev.ltms.fleet.inject.TurnListener -> dev.ltms.fleet.msg.TurnToken",
"dev.ltms.fleet.inject.TurnRegistrar -> dev.ltms.fleet.msg.TurnToken",
"dev.ltms.fleet.msg.MessageService -> dev.ltms.fleet.inject.Injector",
"dev.ltms.fleet.msg.MessageService -> dev.ltms.fleet.inject.Injector$Cancellation",
"dev.ltms.fleet.msg.MessageService -> dev.ltms.fleet.inject.Injector$Delivery",
"dev.ltms.fleet.metrics.FleetMetrics -> dev.ltms.fleet.msg.ReplyInbox",
"dev.ltms.fleet.msg.LeadHeartbeatLoop -> dev.ltms.fleet.metrics.Metrics",
"dev.ltms.fleet.msg.MessageService -> dev.ltms.fleet.metrics.Metrics",
"dev.ltms.fleet.msg.ReplyPushLoop -> dev.ltms.fleet.metrics.Metrics",
"dev.ltms.fleet.msg.LeadHeartbeatLoop -> dev.ltms.fleet.session.MemberSession",
"dev.ltms.fleet.msg.LeadHeartbeatLoop -> dev.ltms.fleet.session.MemberSession$State",
"dev.ltms.fleet.session.SessionManager -> dev.ltms.fleet.msg.TurnToken"
);
private static final String ROOT_PACKAGE = "dev.ltms.fleet.";
@Test
void packagesAreFreeOfCycles() {
var classes = new ClassFileImporter()
JavaClasses classes = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("dev.ltms.fleet");
checkBaselineMatchesTodaysEdges(classes);
SliceRule rule = SlicesRuleDefinition.slices()
.matching("dev.ltms.fleet.(*)..")
.should().beFreeOfCycles();
// fleetd #131 step 1: move ConnectionIdentity so authz stops depending on the
// MCP layer. Evidence: auth/CallerResolver.java:3 imports mcp.ConnectionIdentity;
// mcp/FleetMcp.java:3-7 imports auth.AuditLog, Authz, CallerResolver, Principal,
// Role.
rule = ignoreCycle(rule, "auth", "mcp");
// fleetd #131 step 2: PrimaryRegistry is used by loops in msg; move it, or put
// an interface between msg and mcp. Evidence: msg/ReplyPushLoop.java:5 and
// msg/LeadHeartbeatLoop.java:5 import mcp.PrimaryRegistry; mcp/FleetMcp.java:15-18
// imports msg.LeadChannel, LeadMessage, MessageService, Rendezvous.
rule = ignoreCycle(rule, "mcp", "msg");
// fleetd #131 -- found while implementing this test, NOT one of the ticket's
// original three; it names its own follow-up step before removal. Evidence:
// inject/CompletionResolver.java:4-5, inject/Injector.java:6 and
// inject/TurnListener.java:3 import msg.Rendezvous / msg.TurnToken;
// msg/MessageService.java:6 imports inject.Injector.
rule = ignoreCycle(rule, "inject", "msg");
// fleetd #131 -- same as above, its own follow-up. Evidence:
// metrics/FleetMetrics.java:3 imports msg.ReplyInbox; msg/MessageService.java:7-8,
// msg/LeadHeartbeatLoop.java:6-7 and msg/ReplyPushLoop.java:6-7 import
// metrics.FleetMetrics / metrics.Metrics.
rule = ignoreCycle(rule, "metrics", "msg");
// fleetd #131 -- same as above, its own follow-up. Evidence:
// session/SessionManager.java:7 imports msg.TurnToken;
// msg/LeadHeartbeatLoop.java:8 imports session.MemberSession.
rule = ignoreCycle(rule, "msg", "session");
for (String edge : BASELINE_EDGES) {
String[] originAndTarget = edge.split(" -> ");
rule = rule.ignoreDependency(originAndTarget[0], originAndTarget[1]);
}
rule.check(classes);
}
/**
* Accepts today's known cycle between two top-level packages, and nothing else.
* Ignoring both directions removes exactly this pair from cycle detection; every
* other dependency -- including any new one added later, between these same two
* packages or any other pair -- is still checked.
* Fails with the exact offending edge when the live code and {@link #BASELINE_EDGES}
* disagree: a dependency crossing a baselined package pair that is not in the baseline,
* or a baseline entry whose dependency no longer exists.
*/
private static SliceRule ignoreCycle(SliceRule rule, String packageA, String packageB) {
return rule
.ignoreDependency(residesIn(packageA), residesIn(packageB))
.ignoreDependency(residesIn(packageB), residesIn(packageA));
private static void checkBaselineMatchesTodaysEdges(JavaClasses classes) {
Set<String> baselinedPackagePairs = new TreeSet<>();
for (String edge : BASELINE_EDGES) {
String[] originAndTarget = edge.split(" -> ");
baselinedPackagePairs.add(unorderedPair(
topLevelPackageOf(originAndTarget[0]), topLevelPackageOf(originAndTarget[1])));
}
Set<String> liveEdgesInBaselinedPairs = new TreeSet<>();
for (JavaClass javaClass : classes) {
for (Dependency dependency : javaClass.getDirectDependenciesFromSelf()) {
JavaClass origin = dependency.getOriginClass();
JavaClass target = dependency.getTargetClass();
String originPackage = topLevelPackageOf(origin.getFullName());
String targetPackage = topLevelPackageOf(target.getFullName());
if (originPackage.isEmpty() || targetPackage.isEmpty() || originPackage.equals(targetPackage)) {
continue;
}
if (baselinedPackagePairs.contains(unorderedPair(originPackage, targetPackage))) {
liveEdgesInBaselinedPairs.add(origin.getFullName() + " -> " + target.getFullName());
}
}
}
List<String> problems = new ArrayList<>();
for (String liveEdge : liveEdgesInBaselinedPairs) {
if (!BASELINE_EDGES.contains(liveEdge)) {
String[] originAndTarget = liveEdge.split(" -> ");
problems.add("new dependency not in the baseline: " + liveEdge
+ " (packages " + topLevelPackageOf(originAndTarget[0])
+ " -> " + topLevelPackageOf(originAndTarget[1]) + ")");
}
}
for (String baselineEdge : BASELINE_EDGES) {
if (!liveEdgesInBaselinedPairs.contains(baselineEdge)) {
String[] originAndTarget = baselineEdge.split(" -> ");
problems.add("stale baseline entry, no such dependency exists: " + baselineEdge
+ " (packages " + topLevelPackageOf(originAndTarget[0])
+ " -> " + topLevelPackageOf(originAndTarget[1]) + ")");
}
}
if (!problems.isEmpty()) {
fail("PackageCyclesTest baseline is out of date:\n " + String.join("\n ", problems));
}
}
private static DescribedPredicate<JavaClass> residesIn(String topLevelPackage) {
return Predicates.resideInAPackage("dev.ltms.fleet." + topLevelPackage + "..");
private static String unorderedPair(String packageA, String packageB) {
return packageA.compareTo(packageB) <= 0 ? packageA + "|" + packageB : packageB + "|" + packageA;
}
private static String topLevelPackageOf(String fullyQualifiedClassName) {
if (!fullyQualifiedClassName.startsWith(ROOT_PACKAGE)) {
return "";
}
String rest = fullyQualifiedClassName.substring(ROOT_PACKAGE.length());
int dot = rest.indexOf('.');
return dot < 0 ? "" : rest.substring(0, dot);
}
}
@@ -47,6 +47,28 @@ class AuthzTest {
+ "relies on this gate refusing it first");
}
/**
* {@code fleet_inbox} collects the mail queued for the caller's own pane, so it is gated on
* terminal ownership and on nothing else: every role may do it for itself, and no role may do
* it for another pane.
*/
@Test
void collectingAnInboxIsOnlyEverForTheCallersOwnPane() {
for (Principal self : new Principal[]{WORKER_A, ARCH_DESIGN, COLLABORATOR, OBSERVER}) {
assertTrue(Authz.permits(self, INBOX, self.terminal()),
self.role() + " must be able to collect the mail for its own pane");
assertFalse(Authz.permits(self, INBOX, "term_someone_else"),
self.role() + " must not be able to collect another pane's mail");
}
// A lead carries a pane too, so it collects its own mail on the same rule.
assertTrue(Authz.permits(Principal.leader("opus", "term_lead", 800), INBOX, "term_lead"));
// The unnamed primary owns no pane, so there is no inbox it could be asking for.
assertFalse(Authz.permits(PRIMARY, INBOX, "term_a"),
"a caller with no pane of its own has no inbox to collect");
assertFalse(Authz.permits(WORKER_A, INBOX, null),
"a missing terminal must never match an owner");
}
@Test
void orchestrationBelongsToThePrimaryAlone() {
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, SEND, DRAIN}) {
@@ -288,14 +310,17 @@ class AuthzTest {
}
/**
* Every action beyond READ/METRICS/REPLY/ASK, asserted denied for an observer — including
* {@code TASK_READ}, which is the entire point of this role: an unconfigured pane must not be
* able to poll a ticket or read another session's status.
* Every action beyond READ/METRICS/REPLY/ASK/INBOX/SEND, asserted denied for an observer —
* including {@code TASK_READ}, which is the entire point of this role: an unconfigured pane
* must not be able to poll a ticket or read another session's status. The exempt set is the
* three only-as-itself actions plus the two open reads. {@code SEND} is excluded here and
* given its own matrix below, since — unlike every action in this loop — its grant is
* conditional on the target, not fixed.
*/
@Test
void anObserverIsDeniedEverythingBeyondReadMetricsReplyAndAsk() {
void anObserverIsDeniedEverythingBeyondReadMetricsReplyAskInboxAndSend() {
for (Authz.Action a : Authz.Action.values()) {
if (a == READ || a == METRICS || a == REPLY || a == ASK) {
if (a == READ || a == METRICS || a == REPLY || a == ASK || a == INBOX || a == SEND) {
continue;
}
assertFalse(Authz.permits(OBSERVER, a, "term_observer", target -> true),
@@ -312,4 +337,53 @@ class AuthzTest {
assertFalse(OBSERVER.isSpawnedMember());
assertTrue(OBSERVER.isObserver());
}
// ── the observer SEND matrix ────────────────────────────────────────────────────────────────
/**
* {@code SEND} for an observer is the one grant that is conditional rather than fixed, exactly
* like a collaborator's: flipping only the observer-target classifier's answer flips only this
* outcome.
*/
@Test
void anObserverMaySendOnlyWhenTheClassifierAcceptsTheTargetAsAnObserver() {
assertTrue(Authz.permits(OBSERVER, SEND, "term_other_observer",
Authz.NO_KNOWN_LEAD_OR_COLLABORATOR, target -> true),
"the classifier accepting the target as an observer must grant SEND");
assertFalse(Authz.permits(OBSERVER, SEND, "term_other_observer",
Authz.NO_KNOWN_LEAD_OR_COLLABORATOR, target -> false),
"the classifier refusing the target must deny SEND");
assertFalse(Authz.permits(OBSERVER, SEND, "term_other_observer"),
"the real production classifier recognises no terminal as an observer target yet, "
+ "so SEND is refused by the three- and four-argument convenience forms");
}
/**
* Control for the test above: every other action's result for an observer does not move when
* the observer-target classifier does. Only {@code SEND} is wired to it.
*/
@Test
void theObserverTargetClassifierMovesOnlySendForAnObserver() {
for (Authz.Action a : Authz.Action.values()) {
if (a == SEND) {
continue;
}
assertEquals(
Authz.permits(OBSERVER, a, "term_observer"),
Authz.permits(OBSERVER, a, "term_observer",
Authz.NO_KNOWN_LEAD_OR_COLLABORATOR, target -> true),
a + " must not depend on the observer-target classifier at all");
}
}
/**
* An observer's {@code SEND} is gated on a different classifier than a collaborator's: the
* collaborator classifier accepting every target must not itself grant an observer's SEND.
*/
@Test
void anObserversSendDoesNotMoveOnTheCollaboratorClassifier() {
assertFalse(Authz.permits(OBSERVER, SEND, "term_lead", target -> true),
"an observer's SEND must consult the observer-target classifier, never the "
+ "lead-or-collaborator one");
}
}
@@ -912,4 +912,116 @@ class CallerResolverTest {
assertFalse(r.knownLeadOrCollaborator().test("term_a"));
}
// ── observerSendTarget() reads the same maps and functions resolve() does, in the same order ──
/**
* A terminal this resolver recognises as none of the privileged roles is exactly the one
* {@code resolve} would itself hand back {@link Role#OBSERVER} for.
*/
@Test
void observerSendTargetIsTrueForATerminalKnownAsNoOtherRole() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_lead", "opus-5.0"), new MemberRegistry(null),
t -> "term_worker".equals(t) ? MemberRole.DEV : null,
() -> Map.of("term_collab", "ops"));
assertTrue(r.observerSendTarget().test("term_other"));
}
/**
* A configured lead's own terminal is reachable: an observer may open a conversation with a
* lead, and this is the classifier that grant is checked against.
*/
@Test
void observerSendTargetIsTrueForALeadTerminal() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_lead", "opus-5.0"), new MemberRegistry(null), t -> null, Map::of);
assertTrue(r.observerSendTarget().test("term_lead"),
"a lead's own terminal must be reachable from an observer pane");
}
/**
* A pane named as a lead AND bound to an architect slot resolves as the lead, because
* {@code resolve} reads the lead map first — so the classifier must accept it, or a pane's
* resolved role and its reachability would disagree.
*/
@Test
void observerSendTargetIsTrueForALeadTerminalThatIsAlsoABoundArchitectSlot() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_a", "opus-5.0"),
boundMembers("architect:lead-designer", MemberRole.ARCHITECT), t -> null, Map::of);
assertEquals(Role.PRIMARY, r.resolve("127.0.0.1", 42, null).role(),
"premise: the lead map is read before the architect registry");
assertTrue(r.observerSendTarget().test("term_a"));
}
@Test
void observerSendTargetIsFalseForACollaboratorTerminal() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_lead", "opus-5.0"),
new MemberRegistry(null), t -> null, () -> Map.of("term_collab", "ops"));
assertFalse(r.observerSendTarget().test("term_collab"),
"a collaborator's own terminal must never be reachable from an observer pane");
// CONTROL: the same wiring, a target recognised as no configured role at all.
assertTrue(r.observerSendTarget().test("term_other"));
}
@Test
void observerSendTargetIsFalseForALiveSpawnedMembersTerminal() {
// Covers both a worker and an architect: spawnedMemberRole.apply(target) is non-null for
// either, and resolve() never falls through to OBSERVER once it is.
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
new MemberRegistry(null),
t -> switch (t) {
case "term_worker" -> MemberRole.DEV;
case "term_architect" -> MemberRole.ARCHITECT;
default -> null;
}, Map::of);
assertFalse(r.observerSendTarget().test("term_worker"));
assertFalse(r.observerSendTarget().test("term_architect"));
// CONTROL: the same wiring, a target the member lookup above answers null for.
assertTrue(r.observerSendTarget().test("term_other"));
}
/**
* A lead map entry does not rescue a terminal a live spawned member occupies: the member
* lookup runs first, exactly as in {@code resolve}.
*/
@Test
void observerSendTargetIsFalseForASpawnedMemberOnATerminalTheLeadMapAlsoNames() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_worker", "opus-5.0"), new MemberRegistry(null),
t -> "term_worker".equals(t) ? MemberRole.DEV : null, Map::of);
assertFalse(r.observerSendTarget().test("term_worker"));
// CONTROL: the same wiring, the same lead map, a terminal no member occupies.
assertTrue(r.observerSendTarget().test("term_other"));
}
@Test
void observerSendTargetIsFalseForABoundArchitectSlotWithNoLiveMember() {
// The edge case resolve() itself carries: a terminal bound to a configured architect slot
// but with no live spawned-member session yet.
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
boundMembers("architect:lead-designer", MemberRole.ARCHITECT), t -> null, Map::of);
assertFalse(r.observerSendTarget().test("term_a"));
// CONTROL: the same wiring, a terminal the bind above never touched.
assertTrue(r.observerSendTarget().test("term_other"));
}
@Test
void observerSendTargetIsFalseForANullTarget() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
new MemberRegistry(null), t -> null, Map::of);
assertFalse(r.observerSendTarget().test(null));
// CONTROL: the same wiring, a non-null target.
assertTrue(r.observerSendTarget().test("term_other"));
}
}
@@ -537,12 +537,13 @@ class FleetConfigTest {
}
/**
* CB-579: {@code tab} is the only field a lead's identity depends on now, so it is required
* whether the entry is creatable or recognise-only — without it the entry can never be found.
* A lead's tab label is a fixed constant, not a per-entry field, so an entry with no {@code
* tab:} is the normal case — it is still found by that constant label in its own {@code
* workspace}, not refused as useless.
*/
@Test
void aLeadWithNoTabRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("useless-lead.yaml");
void aLeadWithNoTabIsAcceptedAndFoundByTheFixedLabel(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-tab-lead.yaml");
Files.writeString(f, """
bind:
port: 8080
@@ -553,9 +554,9 @@ class FleetConfigTest {
""");
FleetConfig cfg = FleetConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
assertTrue(e.getMessage().contains("ghost"), "the message must name the useless entry");
assertTrue(e.getMessage().contains("tab:"), "the message must say what is missing");
assertDoesNotThrow(cfg::validateMembers);
assertEquals(List.of(FleetConfig.Leader.LEAD_TAB_LABEL),
cfg.fleet().leaders().get("ghost").acceptedLabels());
}
/**
@@ -775,22 +776,45 @@ class FleetConfigTest {
}
/**
* fleetd #677: identity is matched on a lead's exact {@code tab} alone, so two leads sharing
* one tab means only one of them is ever found — the guard must catch this independently of
* the member-template checks above.
* A lead's tab label is fixed, so two leads sharing one {@code workspace} would both resolve
* to the one tab named {@code lead} there — the guard must catch this independently of the
* member-template checks above.
*/
@Test
void twoLeadsSharingTheSameExactTabRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("shared-tab.yaml");
void twoLeadersSharingTheSameWorkspaceRefuseToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("shared-workspace.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
leaders:
opus:
tab: "shared tab"
workspace: "shared"
sonnet:
tab: "shared tab"
workspace: "shared"
""");
FleetConfig cfg = FleetConfig.load(f);
IllegalStateException e =
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
assertTrue(e.getMessage().contains("opus"), "the message must name one offending lead");
assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead");
assertTrue(e.getMessage().contains("shared"), "the message must name the shared workspace");
}
/** The workspace collision check is case-insensitive, matching how spaces are looked up. */
@Test
void twoLeadersSharingTheSameWorkspaceInDifferentCaseRefuseToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("shared-workspace-case.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
leaders:
opus:
workspace: "Shared"
sonnet:
workspace: "shared"
""");
FleetConfig cfg = FleetConfig.load(f);
@@ -800,52 +824,77 @@ class FleetConfigTest {
assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead");
}
/**
* fleetd #693: the guard matches tabs case-insensitively, because
* {@code LeadTabScanner} keys its tab map on a lowercased label — two tabs differing only in
* case collide there too, and the guard must catch that independently of the exact-match case
* above.
*/
/** Control for the two tests above: distinct workspaces load cleanly, with no {@code tab:} at all. */
@Test
void twoLeadsSharingTheSameTabInDifferentCaseRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("shared-tab-case.yaml");
void twoLeadersWithDistinctWorkspacesAndNoTabAreAllowed(@TempDir Path dir) throws Exception {
Path f = dir.resolve("distinct-workspaces.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
leaders:
opus:
tab: "Shared Tab"
workspace: "space-opus"
sonnet:
tab: "shared tab"
""");
FleetConfig cfg = FleetConfig.load(f);
IllegalStateException e =
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
assertTrue(e.getMessage().contains("opus"), "the message must name one offending lead");
assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead");
}
/** Control for {@link #twoLeadsSharingTheSameExactTabRefusesToStart}: distinct tabs load cleanly. */
@Test
void twoLeadsWithDistinctExactTabsAreAllowed(@TempDir Path dir) throws Exception {
Path f = dir.resolve("distinct-tabs.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
leaders:
opus:
tab: "opus tab"
sonnet:
tab: "sonnet tab"
workspace: "space-sonnet"
""");
FleetConfig cfg = FleetConfig.load(f);
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
}
/**
* A member tab-label template that can render exactly as the fixed lead tab label would let a
* member's own tab be read back as a lead — refused outright, with no lead needing to be
* configured at all.
*/
@Test
void aFleetTabLabelTemplateThatCanRenderAsTheFixedLeadTabLabelRefusesToStart(@TempDir Path dir)
throws Exception {
Path f = dir.resolve("template-renders-as-lead.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
tabLabel: "lead"
leaders:
opus:
workspace: "fleet"
""");
FleetConfig cfg = FleetConfig.load(f);
IllegalStateException e =
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
assertTrue(e.getMessage().contains("fleet.tabLabel"),
"the message must name the offending template");
assertTrue(e.getMessage().contains(FleetConfig.Leader.LEAD_TAB_LABEL),
"the message must name the fixed lead tab label it collides with");
}
/**
* A collaborator's {@code tab} equal to the fixed lead tab label would shadow a lead sharing
* that space — refused outright.
*/
@Test
void aCollaboratorTabEqualToTheFixedLeadTabLabelRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("collaborator-is-lead.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
collaborators:
impostor:
tab: "lead"
""");
FleetConfig cfg = FleetConfig.load(f);
IllegalStateException e =
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
assertTrue(e.getMessage().contains("impostor"), "the message must name the offending collaborator");
assertTrue(e.getMessage().contains(FleetConfig.Leader.LEAD_TAB_LABEL),
"the message must name the fixed lead tab label it collides with");
}
// ── validatePanePlacementAgainstLeadTabs ────────────────────────────────────────────────────
/**
@@ -874,8 +923,12 @@ class FleetConfigTest {
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
}
/**
* A lead is found by its fixed tab label regardless of its own {@code tab} field, so a
* {@code fleet.leaders} entry with no {@code tab} must still arm the guard.
*/
@Test
void aPanePlacedProfileWithNoLeadTabIsAllowed(@TempDir Path dir) throws Exception {
void aPanePlacedProfileWithNoLeadTabRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("pane-no-tab.yaml");
Files.writeString(f, """
bind:
@@ -888,9 +941,59 @@ class FleetConfigTest {
opus:
profile: gx10
""");
FleetConfig cfg = FleetConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class,
cfg::validatePanePlacementAgainstLeadTabs);
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
assertTrue(e.getMessage().contains("the only fix when a lead triggered this"),
"the message must say placement: tab is the only fix for a lead");
assertFalse(e.getMessage().contains("remove the tab from every fleet.leaders"),
"the message must not send the operator in a circle by advising a tab: removal");
}
/**
* {@code placement:} is optional, and {@link FleetConfig.Profile}'s own compact constructor
* defaults an absent or blank value to {@code "tab"}, so a profile naming no placement at all
* is tab-placed and the guard must not fire for it.
*/
@Test
void aProfileWithNoPlacementKeyDefaultsToTabPlacementAndIsAllowed(@TempDir Path dir)
throws Exception {
Path f = dir.resolve("no-placement-key.yaml");
Files.writeString(f, """
bind:
port: 8080
profiles:
gx10: {}
fleet:
leaders:
opus:
profile: gx10
""");
FleetConfig cfg = FleetConfig.load(f);
assertTrue(cfg.profiles().get("gx10").tabPlacement(),
"Profile's compact constructor defaults an absent placement to \"tab\"");
assertDoesNotThrow(cfg::validatePanePlacementAgainstLeadTabs,
"a profile with no placement: key is tab-placed, not pane-placed");
}
/** Control: no {@code fleet.leaders} entry and no collaborator tab still starts fine. */
@Test
void aPanePlacedProfileWithAnEmptyFleetBlockIsAllowed(@TempDir Path dir) throws Exception {
Path f = dir.resolve("pane-empty-fleet.yaml");
Files.writeString(f, """
bind:
port: 8080
profiles:
gx10:
placement: pane
fleet: {}
""");
assertDoesNotThrow(() -> FleetConfig.load(f).validatePanePlacementAgainstLeadTabs(),
"a leader with no tab feeds nothing into the scanner, so pane placement is safe");
"no fleet.leaders entry and no collaborator tab means pane placement is safe");
}
@Test
@@ -24,6 +24,41 @@ public final class FakeHerdr implements HerdrClient {
/** The foreground PID of the one agent pane (term_a) in the canned {@code pane.process_info}. */
public static final long WORKER_PID = 4242;
/**
* A {@code detection} region of the current Claude Code TUI, whose input box is empty: a caret
* line between two rules, above the footer.
*/
public static final String IDLE_PROMPT_CARET = """
──────────────────────── lead: opus ─
❯
─────────────────────────────────────
lead: opus · Opus 5 (1M context) · ~/LTMS/claude-bridge
⏵⏵ auto mode on (shift+tab to cycle) · ← 1 agent""";
/** The same region with the operator's unsubmitted line still at the caret. */
public static final String DRAFTED_PROMPT_CARET = """
──────────────────────── lead: opus ─
❯ yes, send it to lead: opus
─────────────────────────────────────
lead: opus · Opus 5 (1M context) · ~/LTMS/claude-bridge
⏵⏵ auto mode on (shift+tab to cycle) · ← 1 agent""";
/** A {@code detection} region of an older TUI, which drew a bordered box, with that box empty. */
public static final String IDLE_PROMPT_BOX = """
⏺ done
╭────────────────────────╮
│ > │
╰────────────────────────╯
⏵⏵ auto mode on""";
/** The older TUI's bordered box, still holding the operator's unsubmitted line. */
public static final String DRAFTED_PROMPT_BOX = """
⏺ done
╭────────────────────────╮
│ > fix the issue when I │
╰────────────────────────╯
⏵⏵ auto mode on""";
private final ObjectMapper mapper = new ObjectMapper();
/**
* Thread-safe on purpose. Background loops — {@link dev.ltms.fleet.msg.ReplyPushLoop} and the
@@ -48,12 +83,15 @@ public final class FakeHerdr implements HerdrClient {
private final Map<String, String> processInfoErrorCodeFor = new ConcurrentHashMap<>();
private String tabCloseErrorCode = null;
private final Map<String, String> tabCloseErrorCodeFor = new ConcurrentHashMap<>();
private String workspaceListErrorCode = null;
private String agentSendErrorCode = null;
private boolean noPanes = false;
private volatile String agentStatus = "idle"; // steady-state agent.get status
private volatile String agentType = "claude"; // detected agent kind on agent.get; null = undetected
private volatile String agentSessionId = null; // agent_session.value on agent.get; null = omitted
private volatile String readText = "worker transcript tail"; // canned agent.read output
/** Canned {@code detection}-source output, or {@code null} to serve {@link #readText} there too. */
private volatile String detectionText = null;
private int pinnedStarts = 0; // how many upcoming agent.start calls report a fixed pane
private String pinnedStartTerminal;
private String pinnedStartPane;
@@ -142,6 +180,12 @@ public final class FakeHerdr implements HerdrClient {
return this;
}
/** Make {@code workspace.list} fail with this herdr error code; every other method still succeeds. */
public FakeHerdr workspaceListFailsWith(String code) {
this.workspaceListErrorCode = code;
return this;
}
/**
* Make {@code pane.list} report no panes at all — models a second herdr daemon (CB-185) that
* simply does not host the pane a {@link PaneLocator} is searching for.
@@ -200,12 +244,27 @@ public final class FakeHerdr implements HerdrClient {
return this;
}
/** The text {@code agent.read} returns (the CB-106 completion scrape). */
/**
* The text {@code agent.read} returns (the CB-106 completion scrape). It serves the
* {@code detection} source as well unless {@link #detectionText} overrides that one.
*/
public FakeHerdr readText(String text) {
this.readText = text;
return this;
}
/**
* Override the text {@code agent.read} returns for the {@code detection} and {@code visible}
* sources only — the prompt/footer tail herdr uses for status detection and input-box probing, a
* different region from the transcript the other sources carry. Needed by a test whose subject
* reads the input box, since one {@link #readText} cannot be both a worker's transcript and a
* lead's empty prompt.
*/
public FakeHerdr detectionText(String text) {
this.detectionText = text;
return this;
}
/** Make delivery ({@code agent.prompt} / {@code agent.send_keys}) fail with this error code. */
public FakeHerdr agentSendFailsWith(String code) {
this.agentSendErrorCode = code;
@@ -302,11 +361,17 @@ public final class FakeHerdr implements HerdrClient {
case "ping" -> mapper.readTree(
("{\"type\":\"pong\",\"version\":\"%s\",\"protocol\":%d}")
.formatted(pingVersion, pingProtocol));
case "workspace.list" -> mapper.readTree(("""
case "workspace.list" -> {
if (workspaceListErrorCode != null) {
throw new HerdrException("herdr error [" + workspaceListErrorCode + "]: workspace.list failed",
workspaceListErrorCode, null);
}
yield mapper.readTree(("""
{"type":"workspace_list","workspaces":[
{"workspace_id":"w1","label":"dev-mgnl","focused":true,"pane_count":7,"agent_status":"unknown"},
{"workspace_id":"w2","label":"ltms","focused":false,"pane_count":5,"agent_status":"done"}%s]}""")
.formatted(extraWorkspaces.isEmpty() ? "" : "," + String.join(",", extraWorkspaces)));
}
case "agent.list" -> mapper.readTree(("""
{"type":"agent_list","agents":[
{"terminal_id":"term_a","agent":"claude","agent_status":"idle",
@@ -347,8 +412,13 @@ public final class FakeHerdr implements HerdrClient {
"agent_status":"%s","workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"%s}}""")
.formatted(agentField, agentStatus, sessionField));
}
case "agent.read" -> mapper.readTree(mapper.writeValueAsString(
java.util.Map.of("type", "agent_read", "read", java.util.Map.of("text", readText))));
case "agent.read" -> {
Object source = params instanceof Map<?, ?> m ? m.get("source") : null;
boolean probeSource = "detection".equals(source) || "visible".equals(source);
String text = probeSource && detectionText != null ? detectionText : readText;
yield mapper.readTree(mapper.writeValueAsString(
java.util.Map.of("type", "agent_read", "read", java.util.Map.of("text", text))));
}
case "agent.start" -> {
if (onAgentStart != null) {
onAgentStart.run();
@@ -158,15 +158,24 @@ class LeadTabScannerTest {
return Map.of("lead: opus-5.0", "opus-5.0", "lead: gpt-sol-5.6", "gpt-sol-5.6");
}
/** {@code twoLeads()}'s own space — every lead-label fixture below lives here unless noted. */
private static final String MAIN_SPACE = "main";
/** Wraps a flat label → name map under one space, the shape {@link LeadTabScanner} now takes. */
private static Map<String, Map<String, String>> inSpace(String space, Map<String, String> labelToName) {
return Map.of(space, labelToName);
}
private LeadTabScanner scanner(TopologyHerdr herdr, Map<String, String> tabToName,
AtomicLong clock) {
return new LeadTabScanner(herdr, tabToName, Set.of("fleetd-workers"), TTL, clock::get);
return new LeadTabScanner(herdr, inSpace(MAIN_SPACE, tabToName), Set.of("fleetd-workers"),
TTL, clock::get);
}
private LeadTabScanner scannerWithCollaborators(TopologyHerdr herdr, Map<String, String> tabToName,
Map<String, String> collaboratorTabToName,
AtomicLong clock) {
return new LeadTabScanner(herdr, tabToName, collaboratorTabToName,
return new LeadTabScanner(herdr, inSpace(MAIN_SPACE, tabToName), collaboratorTabToName,
Set.of("fleetd-workers"), TTL, clock::get);
}
@@ -237,14 +246,61 @@ class LeadTabScannerTest {
.tab("w1:t2", "w1", "worker: gx10 #1")
.pane("w1:p1", "w1:t1", "term_opus")
.pane("w1:p2", "w1:t2", "term_worker");
LeadTabScanner s = new LeadTabScanner(herdr, Map.of("lead: opus-5.0", "opus-5.0"),
Set.of(), TTL, new AtomicLong()::get);
LeadTabScanner s = new LeadTabScanner(herdr,
inSpace("fleet", Map.of("lead: opus-5.0", "opus-5.0")), Set.of(), TTL,
new AtomicLong()::get);
assertEquals("opus-5.0", s.get().get("term_opus"),
"a lead sharing the members' workspace is still discovered — the label, not the "
+ "workspace, is what matches it");
}
// ── fleetd #770: space is the uniqueness boundary, not the label alone ──────────────────────
/**
* Two leads can share the exact same label (the fixed {@code lead} tab label) as long as they
* sit in different spaces — each tab resolves to its own space's lead, never the other one's.
*/
@Test
void aTabLabelledLeadResolvesToItsOwnSpacesLeadNotTheOtherSpaces() {
TopologyHerdr herdr = new TopologyHerdr()
.workspace("wa", "space-a")
.workspace("wb", "space-b")
.tab("wa:t1", "wa", "lead")
.tab("wb:t1", "wb", "lead")
.pane("wa:p1", "wa:t1", "term_a")
.pane("wb:p1", "wb:t1", "term_b");
Map<String, Map<String, String>> leadLabelsBySpace = Map.of(
"space-a", Map.of("lead", "alpha"),
"space-b", Map.of("lead", "beta"));
LeadTabScanner s = new LeadTabScanner(herdr, leadLabelsBySpace, Set.of(), TTL,
new AtomicLong()::get);
Map<String, String> leads = s.get();
assertEquals("alpha", leads.get("term_a"), "space-a's tab must resolve to space-a's lead");
assertEquals("beta", leads.get("term_b"), "space-b's tab must resolve to space-b's lead");
}
/**
* A lead's deprecated legacy {@code tab:} label is still matched, but only within that lead's
* own configured space — exactly the shape {@code FleetdAssembly} builds via {@code
* Leader.acceptedLabels()}.
*/
@Test
void aLegacyTabLabelStillResolvesWithinItsOwnSpace() {
TopologyHerdr herdr = new TopologyHerdr()
.workspace("w1", "fleet")
.tab("w1:t1", "w1", "lead: opus")
.pane("w1:p1", "w1:t1", "term_opus");
Map<String, Map<String, String>> leadLabelsBySpace =
Map.of("fleet", Map.of("lead", "opus", "lead: opus", "opus"));
LeadTabScanner s = new LeadTabScanner(herdr, leadLabelsBySpace, Set.of(), TTL,
new AtomicLong()::get);
assertEquals("opus", s.get().get("term_opus"),
"the deprecated tab label must still resolve this lead within its own space");
}
@Test
void aLabelWithNoConfiguredEntryIsIgnored() {
TopologyHerdr herdr = new TopologyHerdr().workspace("w1", "main")
@@ -0,0 +1,180 @@
package dev.ltms.fleet.herdr;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Reading a Claude Code input box, so a paste-and-submit delivery never submits the operator's draft. */
class PromptBoxTest {
private static final String EMPTY = FakeHerdr.IDLE_PROMPT_CARET;
private static final String DRAFTED = FakeHerdr.DRAFTED_PROMPT_CARET;
/** An empty box: the pane draws a placeholder hint (its last submitted prompt) at the caret, faint. */
private static final String HINT_1 = "❯\u00a0\u001b[0m\u001b[2mstart md2trilium work for #2\u001b[0m";
/** Same shape as {@link #HINT_1}, a different placeholder hint. */
private static final String HINT_2 = "❯\u00a0\u001b[0m\u001b[2myes, push them\u001b[0m";
/** An empty box with no hint: the caret itself is drawn grey, with escape codes before the marker. */
private static final String EMPTY_GREY_CARET = "\u001b[0m\u001b[38;2;153;153;153m❯\u00a0\u001b[0m";
/** An empty box with no styling at all. */
private static final String EMPTY_PLAIN = "❯\u00a0";
// --- pure classification -------------------------------------------------
@Test
void anEmptyCaretLineIsAnEmptyBox() {
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(EMPTY));
}
@Test
void aCaretLineHoldingTextIsADraftAndCountsItsCharacters() {
PromptBox.Reading reading = PromptBox.classify(DRAFTED);
assertEquals(PromptBox.State.DRAFT, reading.state());
assertEquals("yes,sendittolead:opus".length(), reading.characters(),
"padding does not count — only what the operator typed");
}
@Test
void aBorderedBoxIsReadToo() {
assertEquals(PromptBox.State.EMPTY, PromptBox.classify(FakeHerdr.IDLE_PROMPT_BOX).state(),
"an older TUI draws a bordered box, and its panes must still be readable");
assertEquals(PromptBox.State.DRAFT, PromptBox.classify(FakeHerdr.DRAFTED_PROMPT_BOX).state());
}
@Test
void aSingleTypedCharacterIsADraft() {
assertEquals(PromptBox.State.DRAFT, PromptBox.classify("❯ f").state());
assertEquals(PromptBox.State.DRAFT, PromptBox.classify("│ > f │").state());
}
@Test
void aCursorBlockInAnOtherwiseEmptyBoxIsEmpty() {
assertEquals(PromptBox.State.EMPTY, PromptBox.classify("❯ █").state(),
"a terminal capture may leave the cursor cell in an empty box");
assertEquals(PromptBox.State.EMPTY, PromptBox.classify("│ > █ │").state());
}
@Test
void theBoxLineIsReadToItsEndWhateverFollowsIt() {
assertEquals(PromptBox.State.DRAFT, PromptBox.classify("❯ half a line\n ⏵⏵ auto mode on").state());
assertEquals(PromptBox.State.EMPTY, PromptBox.classify("❯\n ⏵⏵ auto mode on").state());
}
@Test
void theLastBoxLineOnThePaneIsTheLiveOne() {
assertEquals(PromptBox.State.DRAFT,
PromptBox.classify("❯ an earlier prompt\n⏺ its answer\n❯ typing now").state(),
"the probed region carries scrollback, so earlier prompts sit above the live box");
assertEquals(PromptBox.State.EMPTY,
PromptBox.classify("❯ an earlier prompt\n⏺ its answer\n❯").state());
}
@Test
void aMarkerPartWayAlongALineIsNotABox() {
assertEquals(PromptBox.State.UNREADABLE, PromptBox.classify("⏺ type ❯ to get a prompt").state(),
"a caret the operator quoted is transcript text, not an input box");
assertEquals(PromptBox.State.EMPTY, PromptBox.classify("⏺ type ❯ to get a prompt\n❯").state(),
"and it must not shadow the real box further down");
}
@Test
void aPaneWithNoBoxIsUnreadable() {
assertEquals(PromptBox.State.UNREADABLE, PromptBox.classify("garbled ansi noise").state());
assertEquals(PromptBox.State.UNREADABLE, PromptBox.classify("").state());
assertEquals(PromptBox.State.UNREADABLE, PromptBox.classify(null).state());
}
@Test
void aGeneratingTurnIsUnreadableEvenWithAnEmptyBox() {
assertEquals(PromptBox.State.UNREADABLE,
PromptBox.classify(EMPTY + "\n ✳ Thinking… (12s · esc to interrupt)").state());
}
@Test
void aGeneratingMarkerInScrollbackAboveTheBoxDoesNotMakeThePaneUnreadable() {
assertEquals(PromptBox.State.EMPTY,
PromptBox.classify(" ✳ Thinking… (12s · esc to interrupt)\n⏺ done\n" + EMPTY).state(),
"that marker survives in scrollback, and holding on it would hold every delivery forever");
}
@Test
void aPlaceholderHintReadsAsAnEmptyBox() {
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(HINT_1),
"the hint is the pane's own last prompt, drawn faint — it is not the operator's typing");
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(HINT_2));
}
@Test
void anEmptyBoxWithAGreyCaretReadsAsEmpty() {
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(EMPTY_GREY_CARET),
"the caret's own colour sits before the marker and must not stop the marker matching");
}
@Test
void anEmptyBoxWithNoStylingAtAllReadsAsEmpty() {
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(EMPTY_PLAIN));
}
@Test
void typedTextWithNoStylingIsADraftAndCountsItsCharacters() {
PromptBox.Reading reading = PromptBox.classify("❯\u00a0deploy the thing");
assertEquals(PromptBox.State.DRAFT, reading.state());
assertEquals("deploythething".length(), reading.characters());
}
@Test
void typedTextAfterAFaintHintCountsOnlyTheTextOutsideTheFaintSpan() {
PromptBox.Reading reading =
PromptBox.classify("❯\u00a0\u001b[0m\u001b[2mhint\u001b[0m and typed");
assertEquals(PromptBox.State.DRAFT, reading.state());
assertEquals("andtyped".length(), reading.characters(),
"the faint hint is excluded; only \"and typed\" was drawn plain");
}
// --- the gate ------------------------------------------------------------
@Test
void anEmptyBoxClearsTheGateAndReadsTheVisibleRegionWithStylingKept() {
FakeHerdr herdr = new FakeHerdr().detectionText(EMPTY);
assertTrue(new PromptBox(new AgentControl(herdr)).clearToSubmit("term_a"));
@SuppressWarnings("unchecked")
var params = (java.util.Map<String, Object>) herdr.lastCall("agent.read").params();
assertEquals("visible", params.get("source"),
"the input box is drawn in the visible region, not in transcript scrollback");
assertEquals(false, params.get("strip_ansi"),
"styling must survive the read, or a faint placeholder hint reads as plain typed text");
}
@Test
void aDraftedBoxHoldsTheGate() {
assertFalse(new PromptBox(new AgentControl(new FakeHerdr().detectionText(DRAFTED))).clearToSubmit("term_a"));
}
@Test
void anUnreadablePaneHoldsTheGate() {
assertFalse(new PromptBox(new AgentControl(new FakeHerdr().detectionText("garbled"))).clearToSubmit("term_a"));
}
@Test
void aFailedReadHoldsTheGate() {
assertFalse(new PromptBox(new AgentControl(new FakeHerdr().healthy(false))).clearToSubmit("term_a"),
"a pane this cannot read must never be pasted into");
}
@Test
void theGateClearsAgainOnceTheBoxEmpties() {
FakeHerdr herdr = new FakeHerdr().detectionText(DRAFTED);
PromptBox box = new PromptBox(new AgentControl(herdr));
assertFalse(box.clearToSubmit("term_a"));
herdr.detectionText(EMPTY);
assertTrue(box.clearToSubmit("term_a"));
}
}
@@ -89,6 +89,11 @@ class BackendOutageFlowTest {
return MAPPER.createObjectNode().set("agent", MAPPER.createObjectNode()
.put("terminal_id", "term_primary").put("agent_status", "idle"));
}
if ("agent.read".equals(method)) {
// The lead-nudge paths read the input box before pasting into it.
return MAPPER.createObjectNode().set("read",
MAPPER.createObjectNode().put("text", FakeHerdr.IDLE_PROMPT_CARET));
}
if ("agent.prompt".equals(method)) {
prompts.add(params);
sendLatch.countDown();
@@ -0,0 +1,200 @@
package dev.ltms.fleet.inject;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.msg.TestTurnTokens;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Which route a message takes: offered to a pane that collects its own mail, or typed into the
* pane's terminal. Driven by feeding {@code onStatus}, so no real polling is involved.
*/
class InjectorModServedDeliveryTest {
/** A pane that collects its own mail. */
private static final String MOD = "term_mod";
/** A pane that does not, used as the control for every "nothing was typed" assertion. */
private static final String PTY = "term_pty";
private final FakeHerdr herdr = new FakeHerdr();
private final AtomicLong clock = new AtomicLong(1_000_000);
private final Injector injector = new Injector(new AgentControl(herdr), TurnListener.NOOP,
_ -> true, _ -> {
}, clock::get);
/** The messages typed into a pane, in order. A collected message must never appear here. */
@SuppressWarnings("unchecked")
private List<String> typed() {
return herdr.calls.stream()
.filter(c -> c.method().equals("agent.prompt"))
.map(c -> ((Map<String, Object>) c.params()).get("text").toString())
.toList();
}
@Test
void aPaneThatCollectsItsOwnMailIsNeverTypedInto() {
injector.collectInbox(MOD); // the pane says it collects its own mail
CompletableFuture<Void> delivered =
injector.enqueue(MOD, "do the task", TestTurnTokens.inert(MOD)).completion();
injector.onStatus(MOD, AgentStatus.IDLE); // offers it for collection
assertFalse(delivered.isDone(), "an offered message has not reached the pane yet");
assertEquals(List.of("do the task"), injector.collectInbox(MOD), "the pane collects it");
injector.onStatus(MOD, AgentStatus.IDLE); // the next sample records the delivery
assertTrue(delivered.isDone(), "a collected message is a delivered message");
assertEquals(List.of(), typed(), "nothing was typed into a pane that collects its own mail");
// The control: without it, an injector that typed nothing anywhere would pass the line
// above. Same injector, same herdr, a pane that never collected its mail.
injector.enqueue(PTY, "type this", TestTurnTokens.inert(PTY));
injector.onStatus(PTY, AgentStatus.IDLE);
assertEquals(List.of("type this"), typed(), "control: an ordinary pane is still typed into");
}
@Test
void aPaneThatStopsCollectingHasItsMailTypedInstead() {
injector.collectInbox(MOD);
CompletableFuture<Void> delivered =
injector.enqueue(MOD, "do the task", TestTurnTokens.inert(MOD)).completion();
injector.onStatus(MOD, AgentStatus.IDLE);
assertEquals(List.of(), typed(), "control: while it is still collecting, nothing is typed");
assertFalse(delivered.isDone(), "control: and nothing is reported delivered either");
// The mod stopped calling fleet_inbox, so the pane leaves the window.
clock.addAndGet(PaneInbox.MOD_SERVED_WINDOW_MILLIS + 1);
injector.onStatus(MOD, AgentStatus.IDLE);
assertEquals(List.of("do the task"), typed(), "the message falls back to the terminal route");
assertTrue(delivered.isDone(), "and is reported delivered once it is typed");
assertEquals(List.of(), injector.collectInbox(MOD),
"a message that was typed must not also still be collectable");
}
@Test
void aMessageOfferedForCollectionIsStillReportedAsNotYetDelivered() {
injector.collectInbox(MOD);
injector.enqueue(MOD, "do the task", TestTurnTokens.inert(MOD));
injector.onStatus(MOD, AgentStatus.IDLE);
assertFalse(injector.queuedWaitMillis(MOD) == null,
"an offered-but-uncollected message is still waiting, not delivered");
injector.collectInbox(MOD);
injector.onStatus(MOD, AgentStatus.IDLE);
assertEquals(null, injector.queuedWaitMillis(MOD),
"once collected it is off the queue, the same as a typed message");
}
@Test
void aCollectedMessageIsNotFollowedByAnEnterNudge() {
injector.collectInbox(MOD);
injector.enqueue(MOD, "do the task", TestTurnTokens.inert(MOD));
injector.onStatus(MOD, AgentStatus.IDLE);
injector.collectInbox(MOD);
injector.onStatus(MOD, AgentStatus.IDLE); // records the delivery, arms the pickup latch
injector.onStatus(MOD, AgentStatus.IDLE); // still idle: the typed route nudges Enter here
assertFalse(herdr.called("agent.send_keys"),
"a pane that collects its own mail submits it itself; an Enter there would submit "
+ "whatever its operator is typing");
// The control: the nudge really does fire on the typed route, so the absence above is
// this route's behaviour and not a harness that never nudges at all.
injector.enqueue(PTY, "type this", TestTurnTokens.inert(PTY));
injector.onStatus(PTY, AgentStatus.IDLE);
injector.onStatus(PTY, AgentStatus.IDLE);
assertTrue(herdr.called("agent.send_keys"), "control: a typed message is nudged");
}
@Test
void aCancelledMessageStopsBeingCollectable() {
injector.collectInbox(MOD);
Injector.Delivery delivery = injector.enqueue(MOD, "retracted", TestTurnTokens.inert(MOD));
injector.onStatus(MOD, AgentStatus.IDLE); // offered for collection
assertEquals(Injector.Cancellation.CANCELLED, injector.cancel(delivery),
"an offered message has not reached the pane, so it can still be cancelled");
assertEquals(List.of(), injector.collectInbox(MOD),
"a cancelled message the caller was told never arrived must not arrive later");
// The control: an uncancelled message on the same route really is collectable, so the
// empty list above is the cancel working and not the offer never being made.
injector.enqueue(MOD, "kept", TestTurnTokens.inert(MOD));
injector.onStatus(MOD, AgentStatus.IDLE);
assertEquals(List.of("kept"), injector.collectInbox(MOD), "control: an offer is collectable");
}
@Test
void aMessageThePaneAlreadyCollectedCannotBeCancelled() {
injector.collectInbox(MOD);
// The control first: an offer the pane has not taken really is cancellable, so the
// different answer below is the collection and not a cancel that gave up on this route.
Injector.Delivery untaken = injector.enqueue(MOD, "retracted", TestTurnTokens.inert(MOD));
injector.onStatus(MOD, AgentStatus.IDLE);
assertEquals(Injector.Cancellation.CANCELLED, injector.cancel(untaken),
"control: an uncollected offer is still cancellable");
Injector.Delivery taken = injector.enqueue(MOD, "do the task", TestTurnTokens.inert(MOD));
injector.onStatus(MOD, AgentStatus.IDLE);
assertEquals(List.of("do the task"), injector.collectInbox(MOD), "the pane takes the offer");
// No poll has run since the pane took it, so the entry is still at the head and still
// QUEUED: the state alone cannot tell this case from an uncollected offer.
assertEquals(Injector.Cancellation.DELIVERED, injector.cancel(taken),
"the pane holds this text and will act on it, so nothing can be cancelled");
injector.onStatus(MOD, AgentStatus.IDLE);
assertTrue(taken.completion().isDone(), "the next poll records the delivery");
assertEquals(List.of(), typed(), "and nothing was typed into the pane");
}
@Test
void cancellingALaterMessageLeavesACollectedOneDeliveredOnce() {
injector.collectInbox(MOD);
Injector.Delivery first = injector.enqueue(MOD, "first", TestTurnTokens.inert(MOD));
Injector.Delivery second = injector.enqueue(MOD, "second", TestTurnTokens.inert(MOD));
injector.onStatus(MOD, AgentStatus.IDLE); // offers the head
assertEquals(List.of("first"), injector.collectInbox(MOD), "the pane takes the head");
assertEquals(Injector.Cancellation.CANCELLED, injector.cancel(second),
"a message behind the collected one was never offered, so it cancels");
injector.onStatus(MOD, AgentStatus.IDLE);
assertTrue(first.completion().isDone(),
"cancelling a later message must not lose the record that the head was taken");
assertEquals(List.of(), injector.collectInbox(MOD),
"and the head must not be offered a second time");
// The control: the same injector still hands a later message over, so the empty
// collection above is this one not being re-offered rather than the route going quiet.
injector.onStatus(MOD, AgentStatus.WORKING); // the pane picks the collected message up
injector.onStatus(MOD, AgentStatus.IDLE); // and that turn ends
injector.enqueue(MOD, "third", TestTurnTokens.inert(MOD));
injector.onStatus(MOD, AgentStatus.IDLE);
assertEquals(List.of("third"), injector.collectInbox(MOD), "control: a later message is offered");
assertEquals(List.of(), typed(), "nothing took the terminal route");
}
@Test
void aPaneThatNeverCollectedIsTypedIntoFromTheStart() {
injector.enqueue(PTY, "do the task", TestTurnTokens.inert(PTY));
injector.onStatus(PTY, AgentStatus.IDLE);
assertEquals(List.of("do the task"), typed(), "no poll, no offer: the terminal route applies");
assertFalse(injector.isModServed(PTY));
}
}
@@ -59,6 +59,17 @@ class InjectorTest {
assertEquals(List.of("hello"), sent());
}
@Test
void deliveringToAMemberReadsNoPane() {
// No human types into a spawned member's pane, so its delivery path must not pay for a
// prompt-box read the way a lead's nudge paths do.
injector.enqueue(T, "task", TestTurnTokens.inert(T));
injector.onStatus(T, AgentStatus.IDLE);
assertEquals(List.of("task"), sent());
assertFalse(herdr.called("agent.read"), "a member delivery must not read its pane");
}
@Test
void holdsDeliveryUntilTheWorkerIsAvailable() {
// CB-113: idle alone is not enough — hold until the worker's MCP is connected (ready).
@@ -548,20 +559,21 @@ class InjectorTest {
void readinessGraceExpiryLogsTheMeasuredElapsedTimeNotArithmeticOnConstants() {
// fleetd #501, defect 2: the old line computed "({}s)" as READINESS_GRACE_POLLS *
// POLL_INTERVAL_MILLIS / 1000 — arithmetic on two constants, never a measurement, and wrong
// in the direction that says everything ran on schedule. This stub clock returns two FIXED
// values (1_000ms at the first non-ready sample, 318_412ms at the poll that trips the grace)
// whose difference — 317_412ms — does NOT equal 240 * POLL_INTERVAL_MILLIS (=60_000ms).
// Asserting on that literal, non-derived number is what makes this test able to fail if the
// production code goes back to printing the constant-arithmetic value instead of the
// injected clock's measurement.
long[] readings = {1_000L, 318_412L};
// in the direction that says everything ran on schedule. This stub clock returns three FIXED
// values (an unused enqueue-time stamp, 1_000ms at the first non-ready sample, 318_412ms at
// the poll that trips the grace) whose last two differ — 317_412ms — which does NOT equal
// 240 * POLL_INTERVAL_MILLIS (=60_000ms). Asserting on that literal, non-derived number is
// what makes this test able to fail if the production code goes back to printing the
// constant-arithmetic value instead of the injected clock's measurement.
long[] readings = {0L, 1_000L, 318_412L};
AtomicInteger call = new AtomicInteger(0);
LongSupplier stubClock = () -> {
int i = call.getAndIncrement();
if (i >= readings.length) {
throw new AssertionError("nowMillis read more times than this fixture expects (" + i
+ "); the readiness-not-ready branch should read the clock exactly twice — "
+ "once to stamp the first non-ready sample, once at grace expiry");
+ "); the readiness-not-ready branch should read the clock exactly three times —"
+ " once to stamp the queued message's own enqueue time, once to stamp the "
+ "first non-ready sample, once at grace expiry");
}
return readings[i];
};
@@ -603,6 +615,46 @@ class InjectorTest {
assertEquals(List.of("task"), sent(), "a worker that connects within the grace is delivered to");
}
// ~20 minutes of a continuously busy target at the 250ms prod poll interval; enough to trip
// the queue-wait grace.
private static final int QUEUE_WAIT_SAMPLES = 4800;
@Test
void failsAQueuedMessageWhoseTargetNeverFreesUp() {
// A target that stays WORKING the whole time never reaches the branch that looks at the
// queue at all, so nothing else bounds this. The message must fail rather than wait
// forever, and the caller's future unblocks through the same turn-failure path a readiness
// timeout uses — but presence must not be touched, since this target is merely busy, not
// gone.
Captor cap = new Captor();
List<String> forgotten = new ArrayList<>();
Injector inj = new Injector(new AgentControl(herdr), cap, _ -> true, forgotten::add);
CompletableFuture<Void> f = inj.enqueue(T, "task", TestTurnTokens.inert(T)).completion();
for (int i = 0; i < QUEUE_WAIT_SAMPLES; i++) inj.onStatus(T, AgentStatus.WORKING);
assertEquals(List.of(), sent(), "a target that never frees up is never delivered to");
assertTrue(f.isCompletedExceptionally(), "the caller's future fails instead of hanging forever");
assertEquals(List.of(T), cap.failed, "the awaiting send resolves through the turn-failure path");
assertEquals(List.of(), cap.completed, "a never-freed target is a failure, not a completion");
assertEquals(List.of(), forgotten, "the target is busy, not gone — presence must not be cleared");
assertTrue(inj.activeTargets().isEmpty(), "the target is reclaimed, not polled forever");
}
@Test
void aTargetThatFreesUpBeforeTheQueueWaitGraceIsDeliveredNormally() {
// Positive control: a target that is merely busy for a while, then frees up before the
// grace elapses, is still delivered normally — the long-task case this grace must not break.
Injector inj = new Injector(new AgentControl(herdr), TurnListener.NOOP);
inj.enqueue(T, "task", TestTurnTokens.inert(T));
for (int i = 0; i < 100; i++) inj.onStatus(T, AgentStatus.WORKING); // busy, well under the grace
assertEquals(List.of(), sent());
inj.onStatus(T, AgentStatus.IDLE); // frees up
assertEquals(List.of("task"), sent(), "a target that frees up within the grace is delivered to");
}
@Test
void dropClearsWorkerPresence() {
// CB-114 (finding #1): a vanished worker's readiness must be forgotten so a stale entry cannot
@@ -0,0 +1,93 @@
package dev.ltms.fleet.inject;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** What a pane that collects its own mail may and may not see. */
class PaneInboxTest {
private static final String A = "term_a";
private static final String B = "term_b";
private final AtomicLong clock = new AtomicLong(1_000_000);
private final PaneInbox inbox = new PaneInbox(clock::get);
@Test
void aPaneCollectsItsOwnMailAndLeavesTheNextPanesWhereItIs() {
inbox.offer(A, "for a");
inbox.offer(B, "for b");
assertEquals(List.of("for a"), inbox.drain(A), "a pane sees its own message");
// The control for the assertion below: B's message really is there to be missed, so a
// drain that returned everything would have shown it above.
assertEquals(List.of("for b"), inbox.drain(B), "the other pane's message stayed put");
}
@Test
void aCollectedMessageIsNotHandedOverASecondTime() {
inbox.offer(A, "deliver once");
assertEquals(List.of("deliver once"), inbox.drain(A), "control: the first call hands it over");
assertEquals(List.of(), inbox.drain(A), "a collected message is gone from the inbox");
}
@Test
void messagesComeBackInTheOrderTheyWereOffered() {
inbox.offer(A, "first");
inbox.offer(A, "second");
assertEquals(List.of("first", "second"), inbox.drain(A));
}
@Test
void aPaneIsModServedOnlyWhileItKeepsCollecting() {
assertFalse(inbox.isModServed(A), "a pane that has never collected is not mod-served");
inbox.drain(A);
assertTrue(inbox.isModServed(A), "control: collecting is what makes a pane mod-served");
clock.addAndGet(PaneInbox.MOD_SERVED_WINDOW_MILLIS);
assertTrue(inbox.isModServed(A), "control: the window edge still counts as collecting");
clock.addAndGet(1);
assertFalse(inbox.isModServed(A), "a pane that stopped collecting leaves the window");
}
@Test
void withdrawingTakesBackOnlyWhatThePaneHasNotCollected() {
PaneInbox.Entry collected = inbox.offer(A, "already taken");
inbox.drain(A);
PaneInbox.Entry pending = inbox.offer(A, "not taken yet");
inbox.withdrawAll(A);
assertTrue(collected.taken(), "withdrawing must not un-deliver a collected message");
assertFalse(pending.taken(), "control: the uncollected entry was never handed over");
assertEquals(List.of(), inbox.drain(A), "a withdrawn message is no longer collectable");
}
@Test
void forgettingAPaneDropsBothItsMailAndItsPollRecord() {
inbox.drain(A);
inbox.offer(A, "for a");
assertTrue(inbox.isModServed(A), "control: the pane is mod-served and holding mail");
inbox.forget(A);
assertFalse(inbox.isModServed(A), "a gone pane must not look mod-served to the next one");
assertEquals(List.of(), inbox.drain(A), "a gone pane's mail does not outlive it");
}
@Test
void aMissingTerminalCollectsNothingAndIsNeverModServed() {
assertEquals(List.of(), inbox.drain(null));
assertEquals(List.of(), inbox.drain(" "));
assertFalse(inbox.isModServed(null));
}
}
@@ -114,13 +114,13 @@ class LeadLauncherTest {
"name is lead-<name>-<nonce>-<seq>: " + startedName(herdr));
}
/** The tab is labelled with the configured `tab:` so the scanner finds the lead on the next resolve. */
/** The tab is labelled with the fixed lead tab label so the scanner finds the lead on the next resolve. */
@Test
void labelsTheTabWithTheConfiguredTabValue() {
void labelsTheTabWithTheFixedLeadTabLabel() {
FakeHerdr herdr = new FakeHerdr();
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads();
assertEquals("lead: opus",
assertEquals("lead",
((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
}
@@ -148,6 +148,25 @@ class LeadLauncherTest {
assertFalse(herdr.called("tab.close"), "a labelled tab WITH a live agent must never be closed");
}
/**
* The tab a live lead actually sits in still carries its deprecated legacy {@code tab:} label,
* not the fixed {@code lead} tab label a freshly auto-launched instance would get. Counting must
* still recognise it as the live lead via {@link FleetConfig.Leader#acceptedLabels()}, or a
* daemon restart would read it as missing and launch a second orchestrator next to the first.
*/
@Test
void aLiveLeadInALegacyLabelledTabIsCountedSoNothingIsLaunched() {
FakeHerdr herdr = new FakeHerdr()
.withWorkspace("wL", "fleet")
.withTab("wL", "wL:t1", "lead: opus")
.withAgent("lead-opus", "term_lead", "wL:p1", "wL:t1");
assertEquals(0, launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads(),
"the legacy-labelled live lead must be counted — nothing may be launched");
assertFalse(herdr.called("agent.start"),
"a tab label fixed to a constant must not blind the count to a legacy-labelled lead");
}
/**
* The reason liveness is not "does the label exist". A tab left labelled by a session that has
* since died must not block the relaunch, or one crash disables auto-launch permanently.
@@ -263,7 +282,7 @@ class LeadLauncherTest {
assertFalse(herdr.called("tab.close"), "a tab running an agent again must never be closed");
assertFalse(herdr.called("agent.start"), "the lead is live again — nothing to relaunch");
assertEquals("wL:t1", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("tab_id"));
assertEquals("lead: opus", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"),
assertEquals("lead", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"),
"the pending-close flag must be cleared once the tab is confirmed live again");
}
@@ -285,7 +304,7 @@ class LeadLauncherTest {
@Test
void aHandOpenedLeadWithTheConfiguredTabLabelCountsAsLive() {
FakeHerdr herdr = new FakeHerdr()
.withWorkspace("wX", "main")
.withWorkspace("wX", "fleet")
.withTab("wX", "wX:t1", "lead: opus")
.withAgent("hand-opened", "term_hand", "wX:p1", "wX:t1");
@@ -597,7 +616,7 @@ class LeadLauncherTest {
"terminalId() must be herdr's own generated id: " + started.terminalId());
}
/** The new tab is labelled with the lead's configured {@code tab:}, and AFTER the start. */
/** The new tab is labelled with the fixed lead tab label, and AFTER the start. */
@Test
void relaunchLabelsTheNewTabAfterStarting() {
FakeHerdr herdr = new FakeHerdr();
@@ -606,7 +625,7 @@ class LeadLauncherTest {
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).relaunch("opus");
assertNotNull(started);
assertEquals("lead: opus", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
assertEquals("lead", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
int startIndex = indexOfLastCall(herdr, "agent.start");
int renameIndex = indexOfLastCall(herdr, "tab.rename");
@@ -17,6 +17,7 @@ import dev.ltms.fleet.metrics.Metrics;
import dev.ltms.fleet.msg.InMemoryReplyInbox;
import dev.ltms.fleet.msg.MessageService;
import dev.ltms.fleet.msg.Rendezvous;
import dev.ltms.fleet.peer.MemberRole;
import dev.ltms.fleet.session.FakeWorktrees;
import dev.ltms.fleet.session.SessionManager;
import dev.ltms.fleet.member.ClaudeCodeLauncher;
@@ -269,6 +270,82 @@ class FleetMcpAuthzTest {
"a spawned member's own terminal must stay unreachable, even once the classifier is real");
}
// --- the observer SEND matrix, over MCP's denyFor -------------------------------------------
private static final Principal OBSERVER = Principal.observer("term_observer", 700);
/**
* Wires one real {@link CallerResolver} that recognises a lead, a collaborator, and a live
* spawned worker, leaving "term_other_observer" classified as none of them — so the same
* wiring denies an observer's {@code SEND} to a collaborator and to a member while granting
* it to a lead and to another unclassified pane, proving the refusals are the rule and not a
* missing fixture.
*/
@Test
void anObserverMaySendToALeadOrAnotherObserverButNeverToACollaboratorOrAMember() {
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999);
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
() -> Map.of("term_lead_known", "lead-x"), new MemberRegistry(null),
t -> "term_a".equals(t) ? MemberRole.DEV : null,
() -> Map.of("term_collab_known", "ops2"));
FleetMcp m = mcp(true, callers);
assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_lead_known"),
"an observer must reach a lead's terminal, so a peer session can open a "
+ "conversation with a lead");
assertNotNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_collab_known"),
"an observer must never reach a collaborator's terminal");
assertNotNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_a"),
"an observer must never reach a live spawned member's terminal");
// CONTROL: the same wiring, the same denyFor call, a target recognised as none of the
// configured roles above -- this is what proves the two refusals above are the rule
// working, not a classifier that refuses every target regardless of what it is.
assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_other_observer"),
"an observer must reach another pane that resolves as an observer itself");
}
/**
* As {@link #anObserverMaySendToALeadOrAnotherObserverButNeverToACollaboratorOrAMember}, for a
* terminal bound to a configured architect slot but hosting no live spawned-member session --
* the case {@link CallerResolver#resolve} itself treats separately from a live worker/architect.
*/
@Test
void anObserverMayNotSendToABoundArchitectSlotEither() {
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999);
MemberRegistry members = new MemberRegistry(new FleetConfig.Fleet(Map.of(),
Map.of("lead-designer", new FleetConfig.Slot("sonnet")), Map.of(), Map.of(), null));
assertTrue(members.bind("architect:lead-designer", "term_bound_architect"));
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null, Map::of,
members, t -> null, Map::of);
FleetMcp m = mcp(true, callers);
assertNotNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_bound_architect"),
"a terminal bound to a configured architect slot must stay unreachable to an observer");
// CONTROL: the same wiring, a target the bind above never touched.
assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_other_observer"));
}
/**
* An observer's reach is widened for {@code SEND} alone. It still holds no {@code TASK_READ},
* so it cannot poll a ticket or read a lead's session status, and no {@code SPAWN}/
* {@code STOP}/{@code COORD_SEND}, so it cannot drive the fleet it can now message.
*/
@Test
void anObserverReachingALeadStillHoldsNoTicketReadAndNoLifecycle() {
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999);
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
() -> Map.of("term_lead_known", "lead-x"), new MemberRegistry(null), t -> null, Map::of);
FleetMcp m = mcp(true, callers);
assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_lead_known"),
"premise: this wiring grants the observer's send to that lead");
assertNotNull(m.denyFor(OBSERVER, Authz.Action.TASK_READ, "term_lead_known"));
assertNotNull(m.denyFor(OBSERVER, Authz.Action.SPAWN, null));
assertNotNull(m.denyFor(OBSERVER, Authz.Action.STOP, null));
assertNotNull(m.denyFor(OBSERVER, Authz.Action.COORD_SEND, null));
}
@Test
void theLegacyConstructorLeavesTheGateOpen() {
// The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced.
@@ -422,9 +499,10 @@ class FleetMcpAuthzTest {
/**
* {@link FleetMcp#leadsVisibleTo} is the whole policy decision for {@code fleet_list}'s
* {@code leads} array: visible to exactly the roles that may {@code SEND} to a lead -- the
* primary, an architect, and a collaborator -- never a worker, which holds {@code READ} but
* can never {@code SEND} at all, and never an anonymous caller.
* {@code leads} array: visible to the primary, an architect, and a collaborator -- never a
* worker, which holds {@code READ} but can never {@code SEND} at all, never an observer, which
* reads a lead's address from its filtered {@code panes} rows instead, and never an anonymous
* caller.
*/
@Test
void primaryArchitectAndCollaboratorMaySeeTheLeadsArray() {
@@ -434,6 +512,9 @@ class FleetMcpAuthzTest {
"a collaborator may SEND to a lead, so it must see the leads array to learn where");
assertFalse(FleetMcp.leadsVisibleTo(WORKER_A),
"a worker holds READ but can never SEND, so it must not see the leads array");
assertFalse(FleetMcp.leadsVisibleTo(Principal.observer("term_obs", 700)),
"an observer may SEND to a lead but learns the address from its panes rows, which "
+ "carry no lead name, context window or config dir");
assertFalse(FleetMcp.leadsVisibleTo(ANON), "authenticated as nothing must not see it either");
}
@@ -453,6 +534,28 @@ class FleetMcpAuthzTest {
assertFalse(FleetMcp.membersVisibleTo(ANON), "authenticated as nothing must not see it either");
}
// --- who may see fleet_list's panes array ----------------------------------------------------
/**
* {@link FleetMcp#panesVisibleTo} is the whole policy decision for {@code fleet_list}'s
* {@code panes} array: visible to every role that may {@code SEND} to some other pane -- the
* primary, an architect, a collaborator, and an observer (to a lead or another observer pane,
* with its rows filtered and reduced -- see {@code listFleet}) -- never a worker, never an
* anonymous caller.
*/
@Test
void onlyPrimaryArchitectCollaboratorAndObserverMaySeeThePanesArray() {
assertTrue(FleetMcp.panesVisibleTo(PRIMARY), "the primary must see the panes array");
assertTrue(FleetMcp.panesVisibleTo(ARCH_DESIGN), "an architect must see the panes array");
assertTrue(FleetMcp.panesVisibleTo(COLLABORATOR), "a collaborator must see its own peer roster");
assertFalse(FleetMcp.panesVisibleTo(WORKER_A),
"a worker holds READ but can never SEND, so it must not see the panes array");
assertTrue(FleetMcp.panesVisibleTo(Principal.observer("term_obs", 700)),
"an observer holds SEND to a lead and to another observer pane, so it must see the "
+ "(filtered, reduced) panes array");
assertFalse(FleetMcp.panesVisibleTo(ANON), "authenticated as nothing must not see it either");
}
/**
* Same reasoning as {@link #theFleetListHandlerActuallyConsultsCoordinatorVisibleTo}: the
* predicate above can be perfectly correct while the one production call site never asks it.
@@ -0,0 +1,142 @@
package dev.ltms.fleet.mcp;
import dev.ltms.fleet.auth.CallerResolver;
import dev.ltms.fleet.auth.MemberRegistry;
import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.guard.SubscriptionGuard;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.herdr.PaneLocator;
import dev.ltms.fleet.herdr.WorkspaceControl;
import dev.ltms.fleet.inject.Injector;
import dev.ltms.fleet.member.ClaudeCodeLauncher;
import dev.ltms.fleet.msg.InMemoryReplyInbox;
import dev.ltms.fleet.msg.MessageService;
import dev.ltms.fleet.msg.Rendezvous;
import dev.ltms.fleet.session.FakeWorktrees;
import dev.ltms.fleet.session.SessionManager;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* fleetd #743, driven end to end: a real MCP client over a real HTTP transport, resolved by the
* real {@link CallerResolver} to {@link dev.ltms.fleet.auth.Role#OBSERVER}, sending to another
* unclassified pane. {@link FleetMcpAuthzTest} already proves {@code denyFor} grants this case and
* that the handler calls {@code attributeIfObserver}; this test is the one path that also proves
* the grant is not dead at a second gate (CB-548's two-gate trap) by driving the real
* {@link Injector} to the point of its real herdr call, and reads the exact text the receiving
* pane would see.
*/
class FleetMcpObserverSendDeliveryTest {
private static final String TARGET = "term_other_observer";
private final FakeHerdr herdr = new FakeHerdr();
private final AgentControl agents = new AgentControl(herdr);
private final Injector injector = new Injector(agents);
private final Rendezvous rendezvous = new Rendezvous();
private final MessageService messages = new MessageService(agents, injector, rendezvous,
new InMemoryReplyInbox());
private FleetMcp mcp;
private Server server;
@AfterEach
void tearDown() throws Exception {
if (server != null) {
server.stop();
}
if (mcp != null) {
mcp.close();
}
}
@Test
void anObserversSendIsAttributedAndReachesTheRealInjector() throws Exception {
FleetConfig.Profile cfg = new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null,
"tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(agents, new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
_ -> "tok");
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
// The fake's pane list carries a second pane, "term_shell", whose shell pid is 9001 and
// which hosts no agent -- a herdr-owned pane recognised as no lead, architect, collaborator
// or live spawned member, so the real resolver lands it on the observer floor.
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 9001L);
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
Map::of, new MemberRegistry(null));
mcp = new FleetMcp(messages, workers, sessions, identity, sessions.asPresence(),
new PrimaryRegistry(null), callers, FleetMcp.AuthorizationMode.ENFORCED,
null, FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
FleetMcp.QuarantineSource.none(), null, FleetMcp.OutageSource.none(),
FleetMcp.LeadSeatSource.none(), List.of(), null);
ServletContextHandler handler = new ServletContextHandler();
handler.setContextPath("/");
handler.addServlet(new ServletHolder(mcp.servlet()), "/mcp");
server = new Server(0);
server.setHandler(handler);
server.start();
String baseUrl = "http://127.0.0.1:"
+ ((ServerConnector) server.getConnectors()[0]).getLocalPort();
McpSchema.CallToolResult result = sendFleetSend(baseUrl, TARGET, "hi there");
assertFalse(result.isError(), "an observer sending to another observer must be accepted: "
+ textOf(result));
long waiterDeadline = System.currentTimeMillis() + 3000;
while (!rendezvous.isWaiting(TARGET) && System.currentTimeMillis() < waiterDeadline) {
Thread.sleep(5);
}
assertTrue(rendezvous.isWaiting(TARGET), "the async send must have opened its rendezvous waiter");
injector.onStatus(TARGET, AgentStatus.IDLE); // drives the real delivery attempt to herdr
long deliveryDeadline = System.currentTimeMillis() + 3000;
while (!herdr.called("agent.prompt") && System.currentTimeMillis() < deliveryDeadline) {
Thread.sleep(5);
}
assertTrue(herdr.called("agent.prompt"), "the delivery attempt must have reached herdr");
@SuppressWarnings("unchecked")
Map<String, Object> params = (Map<String, Object>) herdr.lastCall("agent.prompt").params();
assertEquals("[fleet_send from observer term_shell]\nhi there", params.get("text"),
"the receiving pane must see the sender's own daemon-resolved terminal, never a raw "
+ "echo of the content and never a client-supplied name");
}
private static McpSchema.CallToolResult sendFleetSend(String baseUrl, String target, String content) {
McpClientTransport transport = HttpClientStreamableHttpTransport.builder(baseUrl)
.endpoint("/mcp")
.build();
try (McpSyncClient client = McpClient.sync(transport).build()) {
client.initialize();
return client.callTool(McpSchema.CallToolRequest.builder("fleet_send")
.arguments(Map.of("sessionId", target, "content", content, "wait", false))
.build());
}
}
private static String textOf(McpSchema.CallToolResult r) {
return ((McpSchema.TextContent) r.content().getFirst()).text();
}
}
@@ -0,0 +1,178 @@
package dev.ltms.fleet.mcp;
import dev.ltms.fleet.Fleetd;
import dev.ltms.fleet.auth.CallerResolver;
import dev.ltms.fleet.auth.MemberRegistry;
import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.guard.SubscriptionGuard;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.herdr.PaneLocator;
import dev.ltms.fleet.herdr.WorkspaceControl;
import dev.ltms.fleet.inject.Injector;
import dev.ltms.fleet.inject.MemberPresence;
import dev.ltms.fleet.inject.TurnListener;
import dev.ltms.fleet.member.ClaudeCodeLauncher;
import dev.ltms.fleet.msg.InMemoryReplyInbox;
import dev.ltms.fleet.msg.MessageService;
import dev.ltms.fleet.msg.Rendezvous;
import dev.ltms.fleet.session.FakeWorktrees;
import dev.ltms.fleet.session.SessionManager;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* An observer's {@code fleet_send} to a lead, driven end to end: a real MCP client over a real
* HTTP transport, resolved by the real {@link CallerResolver} to
* {@link dev.ltms.fleet.auth.Role#OBSERVER}, through the real {@link MessageService} and the real
* {@link Injector} to the point of its real herdr call.
*
* <p>{@link FleetMcpAuthzTest} proves {@code denyFor} grants this case. This is the path that also
* proves the grant is not dead at the injector's readiness gate: that gate is the production
* {@link Fleetd#deliverableTo} predicate, and the lead's terminal carries no
* {@link MemberPresence} entry, so the delivery can only pass by the lead being a configured lead.
*/
class FleetMcpObserverSendToLeadDeliveryTest {
private static final String LEAD = "term_lead_pane";
private static final String COLLABORATOR = "term_collab_pane";
private final FakeHerdr herdr = new FakeHerdr();
private final AgentControl agents = new AgentControl(herdr);
private final Rendezvous rendezvous = new Rendezvous();
private final MemberPresence presence = new MemberPresence();
private Injector injector;
private MessageService messages;
private FleetMcp mcp;
private Server server;
private String baseUrl;
@BeforeEach
void startServer() throws Exception {
FleetConfig.Profile cfg = new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null,
"tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(agents, new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
_ -> "tok");
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
// The fake's pane list carries a second pane, "term_shell", whose shell pid is 9001 and
// which hosts no agent -- so the real resolver lands the caller on the observer floor.
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 9001L);
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
() -> Map.of(LEAD, "fleet01-lead"), new MemberRegistry(null),
_ -> null, () -> Map.of(COLLABORATOR, "ops"));
Predicate<String> deliverable =
Fleetd.deliverableTo(presence, callers::leads, callers::collaborators);
injector = new Injector(agents, TurnListener.NOOP, deliverable);
messages = new MessageService(agents, injector, rendezvous, new InMemoryReplyInbox());
mcp = new FleetMcp(messages, workers, sessions, identity, presence,
new PrimaryRegistry(null), callers, FleetMcp.AuthorizationMode.ENFORCED,
null, FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
FleetMcp.QuarantineSource.none(), null, FleetMcp.OutageSource.none(),
FleetMcp.LeadSeatSource.none(), List.of(), null);
ServletContextHandler handler = new ServletContextHandler();
handler.setContextPath("/");
handler.addServlet(new ServletHolder(mcp.servlet()), "/mcp");
server = new Server(0);
server.setHandler(handler);
server.start();
baseUrl = "http://127.0.0.1:"
+ ((ServerConnector) server.getConnectors()[0]).getLocalPort();
}
@AfterEach
void tearDown() throws Exception {
if (server != null) {
server.stop();
}
if (mcp != null) {
mcp.close();
}
}
@Test
void anObserversSendToALeadIsAttributedAndReachesTheRealInjector() throws Exception {
assertFalse(presence.isPresent(LEAD),
"premise: the lead's terminal is deliverable only as a configured lead, never "
+ "through a presence entry");
McpSchema.CallToolResult result = sendFleetSend(LEAD, "can we split the review?");
assertFalse(result.isError(), "an observer sending to a lead must be accepted: "
+ textOf(result));
long waiterDeadline = System.currentTimeMillis() + 3000;
while (!rendezvous.isWaiting(LEAD) && System.currentTimeMillis() < waiterDeadline) {
Thread.sleep(5);
}
assertTrue(rendezvous.isWaiting(LEAD), "the async send must have opened its rendezvous waiter");
injector.onStatus(LEAD, AgentStatus.IDLE); // drives the real delivery attempt to herdr
long deliveryDeadline = System.currentTimeMillis() + 3000;
while (!herdr.called("agent.prompt") && System.currentTimeMillis() < deliveryDeadline) {
Thread.sleep(5);
}
assertTrue(herdr.called("agent.prompt"), "the delivery attempt must have reached herdr");
@SuppressWarnings("unchecked")
Map<String, Object> params = (Map<String, Object>) herdr.lastCall("agent.prompt").params();
assertEquals("[fleet_send from observer term_shell]\ncan we split the review?",
params.get("text"),
"a lead must see the sender's own daemon-resolved terminal, never a raw echo of "
+ "the content and never a client-supplied name");
}
/**
* Control for the test above, over the same live server and the same wiring: the grant is
* specific to a lead target, so a collaborator's terminal is still refused at the handler and
* nothing is ever queued for it.
*/
@Test
void thatSameObserverIsStillRefusedACollaboratorsTerminal() {
McpSchema.CallToolResult result = sendFleetSend(COLLABORATOR, "can we split the review?");
assertTrue(result.isError(), "an observer must not reach a collaborator's terminal");
assertFalse(rendezvous.isWaiting(COLLABORATOR),
"a refused send must never open a waiter for its target");
}
private McpSchema.CallToolResult sendFleetSend(String target, String content) {
McpClientTransport transport = HttpClientStreamableHttpTransport.builder(baseUrl)
.endpoint("/mcp")
.build();
try (McpSyncClient client = McpClient.sync(transport).build()) {
client.initialize();
return client.callTool(McpSchema.CallToolRequest.builder("fleet_send")
.arguments(Map.of("sessionId", target, "content", content, "wait", false))
.build());
}
}
private static String textOf(McpSchema.CallToolResult r) {
return ((McpSchema.TextContent) r.content().getFirst()).text();
}
}
@@ -1,5 +1,6 @@
package dev.ltms.fleet.mcp;
import dev.ltms.fleet.Fleetd;
import dev.ltms.fleet.auth.CallerResolver;
import dev.ltms.fleet.auth.MemberRegistry;
import dev.ltms.fleet.auth.Principal;
@@ -1110,6 +1111,255 @@ class FleetMcpTest {
assertTrue(asArchitect.contains("\"sessionId\":\"term_collab\""), asArchitect);
}
// --- fleet_list's panes array -----------------------------------------------------------------
/** Calls the canonical {@code listFleet} overload directly, so a test can set the pane-discovery
* payload and its visibility independently of a real {@code Principal} / MCP exchange. */
private static McpSchema.CallToolResult listFleetWithPanes(FakeHerdr h, FleetMcp.PaneSource panes,
boolean panesVisible) {
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
return FleetMcp.listFleet(
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(),
FleetMcp.LeadSeatSource.none(), new LeadContextGauge(), FleetMcp.LeadConfigDirSource.none(),
Map.of(), "", Map.of(), false,
FleetMcp.CoordinationSource.none(), false, true, true, panes, panesVisible);
}
/**
* A pane whose tab herdr reports with a label gets that label and the exact terminal id
* {@code fleet_send} takes as a target, carried as {@code sessionId}. fleetd #771: the pane's
* workspace carries its herdr space name too, next to {@code workspaceId}.
*/
@Test
void listReportsAPaneRowWithItsTabLabelAndSendableSessionId() {
FakeHerdr h = new FakeHerdr().withTab("w2", "w2:t7", "trinotes");
MemberPresence presence = new MemberPresence();
presence.markPresent("term_a");
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
() -> new PaneLocator(h).tabLabelsByTabId(),
() -> new PaneLocator(h).workspaceLabelsByWorkspaceId(),
Fleetd.deliverableTo(presence, Map::of, Map::of), _ -> false, _ -> false);
String out = textOf(listFleetWithPanes(h, panes, true));
assertTrue(out.contains("\"panes\":["), out);
assertTrue(out.contains("\"sessionId\":\"term_a\""), out);
assertTrue(out.contains("\"label\":\"trinotes\""), out);
assertTrue(out.contains("\"workspaceLabel\":\"ltms\""),
"term_a's agent lives on workspace w2, whose herdr label is \"ltms\": " + out);
assertTrue(out.contains("\"deliverable\":true"), out);
}
/**
* A pane whose tab carries no label known to herdr still gets a row -- a missing label must
* never throw, and must never drop the pane from the array, only report a {@code null} label.
* Pairs with a {@code deliverable} false reading when the target is neither present, a lead,
* nor a collaborator.
*/
@Test
void listReportsAPaneRowWithANullLabelWhenHerdrHasNoneAndNotDeliverable() {
FakeHerdr h = new FakeHerdr();
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
() -> new PaneLocator(h).tabLabelsByTabId(),
() -> new PaneLocator(h).workspaceLabelsByWorkspaceId(),
Fleetd.deliverableTo(new MemberPresence(), Map::of, Map::of), _ -> false, _ -> false);
String out = textOf(listFleetWithPanes(h, panes, true));
assertTrue(out.contains("\"panes\":["), out);
assertTrue(out.contains("\"sessionId\":\"term_a\""), out);
assertTrue(out.contains("\"label\":null"), out);
assertTrue(out.contains("\"deliverable\":false"), out);
}
/**
* fleetd #771: a pane whose agent lives in a workspace that {@code workspace.list} does not
* report (an unknown {@code workspaceId}) still gets a row -- the lookup miss must never throw,
* and must never drop the pane, only report a {@code null} "workspaceLabel".
*/
@Test
void listReportsANullWorkspaceLabelForAnUnknownWorkspaceId() {
// withAgent seeds its pane under workspace_id "wQ", which the fake's workspace.list never
// reports (only "w1"/"w2") -- modelling a workspace the lookup has no entry for.
FakeHerdr h = new FakeHerdr().withAgent("claude-x", "term_unknown_ws", "wQ:p1", "wQ:t1");
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
() -> new PaneLocator(h).tabLabelsByTabId(),
() -> new PaneLocator(h).workspaceLabelsByWorkspaceId(),
_ -> false, _ -> false, _ -> false);
String out = textOf(listFleetWithPanes(h, panes, true));
assertTrue(out.contains("\"sessionId\":\"term_unknown_ws\""), out);
assertTrue(out.contains("\"workspaceId\":\"wQ\""), out);
assertTrue(out.contains("\"workspaceLabel\":null"),
"an unknown workspaceId must project a null workspaceLabel, not throw or drop the row: " + out);
}
/** A caller this role may not show the array to gets no {@code panes} key at all. */
@Test
void listOmitsThePanesArrayWhenTheCallerMayNotSeeIt() {
FakeHerdr h = new FakeHerdr();
String out = textOf(listFleetWithPanes(h, FleetMcp.PaneSource.none(), false));
assertFalse(out.contains("\"panes\""), out);
}
/**
* The tab-label scan behind {@code panes} shares no failure path with the rest of
* {@code listFleet} -- a {@code workspace.list}/{@code tab.list} failure costs only the
* labels in the {@code panes} row (each renders {@code null}), never the {@code leads}/
* {@code members} arrays, which never needed that scan at all. fleetd #771: the same
* {@code workspace.list} failure costs {@code workspaceLabel} the same way.
*/
@Test
void listStillReportsEveryOtherArrayWhenTheLabelScanFails() {
FakeHerdr h = new FakeHerdr().workspaceListFailsWith("unavailable");
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
() -> new PaneLocator(h).tabLabelsByTabId(),
() -> new PaneLocator(h).workspaceLabelsByWorkspaceId(),
Fleetd.deliverableTo(new MemberPresence(), Map::of, Map::of), _ -> false, _ -> false);
McpSchema.CallToolResult res = listFleetWithPanes(h, panes, true);
assertNotEquals(Boolean.TRUE, res.isError(), textOf(res));
String out = textOf(res);
assertTrue(out.contains("\"panes\":["), out);
assertTrue(out.contains("\"sessionId\":\"term_a\""), out);
assertTrue(out.contains("\"label\":null"), out);
assertTrue(out.contains("\"workspaceLabel\":null"),
"a workspace.list failure must not cost the leads/members arrays, only a null "
+ "workspaceLabel: " + out);
assertTrue(out.contains("\"leads\":[]"), "a label-scan failure must not cost the leads array: " + out);
assertTrue(out.contains("\"members\":[]"), "a label-scan failure must not cost the members array: " + out);
}
/**
* fleetd #756: a pane bound to a configured architect slot with no live member session must
* report {@code role: "architect"}, read from {@link CallerResolver#boundToArchitectSlot} —
* the same classifier {@link CallerResolver#observerSendTarget} refuses as a {@code SEND}
* target — rather than falling through to {@code "observer"}.
*/
@Test
void listReportsArchitectForASlotBoundPaneWithNoLiveMember() {
FakeHerdr h = new FakeHerdr()
.withAgent("claude-arch", "term_unoccupied_architect", "w2:pArch", "w2:tArch");
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
Map::of, Map::of, _ -> false, "term_unoccupied_architect"::equals, _ -> false);
String out = textOf(listFleetWithPanes(h, panes, true));
assertTrue(out.contains("\"sessionId\":\"term_unoccupied_architect\""), out);
assertTrue(out.contains("\"role\":\"architect\""),
"a slot-bound pane with no live session must read \"architect\", not the generic "
+ "\"observer\" fallback: " + out);
}
/**
* Calls the canonical {@code listFleet} overload directly with an explicit {@code leads}/
* {@code collaborators} payload and {@code callerIsObserver}, mirroring exactly what the real
* {@code fleet_list} handler computes for an observer caller: {@code panesVisible} true,
* {@code leadsVisible}/{@code membersVisible}/{@code collaboratorsVisible} false.
*/
private static McpSchema.CallToolResult listFleetAsObserver(FakeHerdr h, Map<String, String> leads,
Map<String, String> collaborators, FleetMcp.PaneSource panes) {
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
return FleetMcp.listFleet(
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(),
FleetMcp.LeadSeatSource.none(), new LeadContextGauge(), FleetMcp.LeadConfigDirSource.none(),
leads, "", collaborators, false,
FleetMcp.CoordinationSource.none(), false, false, false, panes, true, true);
}
/**
* An observer's {@code fleet_list} carries a {@code panes} key, filtered to
* {@link CallerResolver#observerSendTarget} (so a lead's pane survives, while a spawned
* member's pane, a collaborator's pane and an unoccupied architect-slot pane are all absent)
* and every surviving row reduced to exactly {@code sessionId}, {@code label}, {@code status},
* {@code role}, {@code deliverable} — never {@code paneId}, {@code workspaceId},
* {@code workspaceLabel} (a space name is host shape, a stronger disclosure than a pane id, so
* it stays out of the reduced row too), {@code tabId}, {@code agentType}, or {@code cwd}.
*/
@Test
void listFiltersAndReducesThePanesArrayForAnObserver() {
MemberRegistry members = new MemberRegistry(new FleetConfig.Fleet(Map.of(),
Map.of("lead-designer", new FleetConfig.Slot("sonnet")), Map.of(), Map.of(), null));
assertTrue(members.bind("architect:lead-designer", "term_architect_pane"));
CallerResolver callers = CallerResolver.withLeadsAndMembers(null, false, null,
() -> Map.of("term_lead_pane", "fleet01-lead"), members,
t -> "term_member_pane".equals(t) ? MemberRole.DEV : null,
() -> Map.of("term_collab_pane", "ops"));
FakeHerdr h = new FakeHerdr()
.withAgent("claude-sendable", "term_sendable", "w2:pS", "w2:tS")
.withAgent("claude-lead", "term_lead_pane", "w2:pL", "w2:tL")
.withAgent("claude-member", "term_member_pane", "w2:pM", "w2:tM")
.withAgent("claude-collab", "term_collab_pane", "w2:pC", "w2:tC")
.withAgent("claude-arch", "term_architect_pane", "w2:pA", "w2:tA")
.withTab("w2", "w2:tS", "trinotes");
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
() -> new PaneLocator(h).tabLabelsByTabId(),
() -> new PaneLocator(h).workspaceLabelsByWorkspaceId(), _ -> true,
callers::boundToArchitectSlot, callers.observerSendTarget());
String out = textOf(listFleetAsObserver(h, callers.leads(),
Map.of("term_collab_pane", "ops"), panes));
assertTrue(out.contains("\"panes\":["), out);
assertTrue(out.contains("\"sessionId\":\"term_sendable\""),
"an ordinary unclassified pane must still be sendable and visible: " + out);
assertTrue(out.contains("\"sessionId\":\"term_lead_pane\""),
"a lead's pane is where an observer reads the sessionId its send needs: " + out);
assertTrue(out.contains("\"role\":\"lead\""),
"the lead's row must name the role, so an observer can tell it from a peer pane: " + out);
assertFalse(out.contains("term_member_pane"), "a spawned member's pane must not be enumerated: " + out);
assertFalse(out.contains("term_collab_pane"), "a collaborator's pane must not be enumerated: " + out);
assertFalse(out.contains("term_architect_pane"),
"an unoccupied architect-slot pane must not be enumerated: " + out);
assertFalse(out.contains("\"paneId\""), "an observer's row must never carry paneId: " + out);
assertFalse(out.contains("\"workspaceId\""), "an observer's row must never carry workspaceId: " + out);
assertFalse(out.contains("\"workspaceLabel\""),
"an observer's row must never carry workspaceLabel: " + out);
assertFalse(out.contains("\"tabId\""), "an observer's row must never carry tabId: " + out);
assertFalse(out.contains("\"agentType\""), "an observer's row must never carry agentType: " + out);
assertFalse(out.contains("\"cwd\""), "an observer's row must never carry cwd: " + out);
}
/**
* Control for the test above: a primary's {@code panes} row is unchanged by fleetd #758 —
* {@code callerIsObserver} false keeps every field, including {@code paneId} and a spawned
* member's {@code cwd}.
*/
@Test
void listKeepsTheFullPaneRowForAPrimaryIncludingCwdAndPaneId() {
FakeHerdr h = new FakeHerdr();
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
MemberSession spawned = sessions.acquire("ltms-local", "/worktree/member-1", null, null);
h.withAgent("claude-member", spawned.terminalId(), "w9:pMember", "w9:tMember");
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(Map::of, Map::of, _ -> true, _ -> false, _ -> false);
McpSchema.CallToolResult res = FleetMcp.listFleet(
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(),
FleetMcp.LeadSeatSource.none(), new LeadContextGauge(), FleetMcp.LeadConfigDirSource.none(),
Map.of(), "", Map.of(), false,
FleetMcp.CoordinationSource.none(), true, true, true, panes, true, false);
String out = textOf(res);
assertTrue(out.contains("\"sessionId\":\"" + spawned.terminalId() + "\""), out);
assertTrue(out.contains("\"paneId\":\"w9:pMember\""),
"a primary must still see the fleet_stop handle: " + out);
assertTrue(out.contains("\"cwd\":\"/worktree/member-1\""),
"a primary must still see a spawned member's worktree path: " + out);
assertTrue(out.contains("\"role\":\"dev\""), out);
}
/**
* fleetd #421: {@code mailbox.pending} counts only broker-ready messages, so a blocked lead's
* normal, healthy state is {@code "pending": 0} next to a non-empty {@code held[]} — which
@@ -40,7 +40,7 @@ class LeadCoordLoopTest {
@Test
void deliversAHeldMessageToTheLeadPaneAndAcksIt() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "the merge is blocked"));
var herdr = new FakeHerdr().agentStatus("idle");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("idle");
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
@@ -57,7 +57,7 @@ class LeadCoordLoopTest {
@Test
void redeliveryOfAMessageAlreadyWrittenToThePaneIsAckedWithoutAnotherPaneWrite() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "recover me"));
var herdr = new FakeHerdr().agentStatus("idle");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("idle");
var loop = loop(channel, herdr, Map.of(LEAD_TERM, SELF));
loop.tick();
@@ -71,7 +71,7 @@ class LeadCoordLoopTest {
@Test
void aRedeliveryIsAckedEvenWhileTheLeadIsMidTurn() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "recover me"));
var herdr = new FakeHerdr().agentStatus("idle");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("idle");
var loop = loop(channel, herdr, Map.of(LEAD_TERM, SELF));
loop.tick();
@@ -91,7 +91,7 @@ class LeadCoordLoopTest {
@Test
void leavesTheMessageUnackedWhenTheLeadIsMidTurn() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
var herdr = new FakeHerdr().agentStatus("working");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("working");
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
@@ -103,7 +103,7 @@ class LeadCoordLoopTest {
@Test
void leavesTheMessageUnackedWhenNoLeadPaneIsKnown() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
var herdr = new FakeHerdr().agentStatus("idle");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("idle");
loop(channel, herdr, Map.of()).tick();
@@ -115,7 +115,8 @@ class LeadCoordLoopTest {
@Test
void leavesTheMessageUnackedWhenHerdrRefusesTheInjection() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
var herdr = new FakeHerdr().agentStatus("idle").agentSendFailsWith("agent_not_found");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET)
.agentStatus("idle").agentSendFailsWith("agent_not_found");
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
@@ -126,7 +127,7 @@ class LeadCoordLoopTest {
@Test
void resolvesTheLeadByNameWhenSeveralAreKnown() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
var herdr = new FakeHerdr().agentStatus("idle");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("idle");
// Two leads on this daemon; only one carries the coord-id the mailbox is owned as.
var leads = new java.util.LinkedHashMap<String, String>();
leads.put("term_other", "some-other-lead");
@@ -142,7 +143,7 @@ class LeadCoordLoopTest {
@Test
void holdsWhenSeveralLeadsAreKnownAndNoneCarriesTheCoordId() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
var herdr = new FakeHerdr().agentStatus("idle");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("idle");
var leads = new java.util.LinkedHashMap<String, String>();
leads.put("term_one", "lead-one");
leads.put("term_two", "lead-two");
@@ -159,7 +160,7 @@ class LeadCoordLoopTest {
var channel = new FakeLeadChannel(SELF)
.hold(new LeadMessage("m1", PEER, SELF, "first"))
.hold(new LeadMessage("m2", PEER, SELF, "second"));
var herdr = new FakeHerdr().agentStatus("idle");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("idle");
var loop = loop(channel, herdr, Map.of(LEAD_TERM, SELF));
loop.tick();
@@ -175,10 +176,51 @@ class LeadCoordLoopTest {
@Test
void anEmptyMailboxNeverTouchesHerdr() {
var channel = new FakeLeadChannel(SELF);
var herdr = new FakeHerdr().agentStatus("idle");
var herdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET).agentStatus("idle");
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
assertEquals(0, herdr.calls.size(), "an idle fleet must not poll a pane's status every tick");
}
@Test
void aLeadWithUnsubmittedTextInItsPromptBoxKeepsTheMessageHeldAndUnacked() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "the merge is blocked"));
var herdr = new FakeHerdr().detectionText(FakeHerdr.DRAFTED_PROMPT_CARET).agentStatus("idle");
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
assertEquals(0, prompts(herdr).size(),
"delivery pastes and submits, so it must not land on a half-typed line");
assertEquals(List.of(), channel.acked(), "an undelivered message stays on the broker");
assertFalse(channel.peek().isEmpty(), "and is still held");
}
@Test
void aMessageHeldForADraftIsDeliveredOnALaterTick() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "the merge is blocked"));
var herdr = new FakeHerdr().detectionText(FakeHerdr.DRAFTED_PROMPT_CARET).agentStatus("idle");
var loop = loop(channel, herdr, Map.of(LEAD_TERM, SELF));
loop.tick();
assertEquals(0, prompts(herdr).size());
herdr.detectionText(FakeHerdr.IDLE_PROMPT_CARET);
loop.tick();
assertEquals(1, prompts(herdr).size(), "the held message lands once the box is empty");
assertEquals(List.of("m1"), channel.acked());
}
@Test
void anUnreadablePaneKeepsTheMessageHeld() {
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "the merge is blocked"));
var herdr = new FakeHerdr().detectionText("garbled ansi noise with no input box").agentStatus("idle");
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
assertEquals(0, prompts(herdr).size(), "a pane whose box cannot be found may be holding a draft");
assertEquals(List.of(), channel.acked());
}
}
@@ -7,6 +7,7 @@ import ch.qos.logback.core.read.ListAppender;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.HerdrClient;
import dev.ltms.fleet.lead.LeadContextGauge;
@@ -695,6 +696,8 @@ class LeadHeartbeatLoopTest {
private final List<String> sentTexts = new ArrayList<>();
private final List<String> promptTargets = new ArrayList<>();
private boolean throwOnNextSend = false;
/** What {@code agent.read} reports — the loop reads the lead's input box before it nudges. */
private String paneTail = FakeHerdr.IDLE_PROMPT_CARET;
FailableHerdrClient(String lead) {
this.lead = lead;
@@ -704,6 +707,10 @@ class LeadHeartbeatLoopTest {
throwOnNextSend = true;
}
void paneTail(String tail) {
this.paneTail = tail;
}
List<String> sentTexts() {
return List.copyOf(sentTexts);
}
@@ -722,6 +729,10 @@ class LeadHeartbeatLoopTest {
.put("terminal_id", lead)
.put("agent_status", "idle"));
}
if ("agent.read".equals(method)) {
return MAPPER.createObjectNode()
.set("read", MAPPER.createObjectNode().put("text", paneTail));
}
if ("agent.prompt".equals(method)) {
Map<String, Object> p = params instanceof Map ? (Map<String, Object>) params : Map.of();
if (throwOnNextSend) {
@@ -738,4 +749,49 @@ class LeadHeartbeatLoopTest {
public void close() {
}
}
// ── the operator's own prompt box ────────────────────────────────────────────────────────────
@Test
void tickHoldsTheNudgeWhileTheLeadsPromptBoxHoldsUnsubmittedText() {
var herdr = new FailableHerdrClient(LEAD);
herdr.paneTail(FakeHerdr.DRAFTED_PROMPT_CARET);
var now = new AtomicLong(NOW);
@SuppressWarnings("unchecked")
List<MemberSession>[] rosterBox = new List[]{List.of()};
InMemoryReplyInbox inbox = new InMemoryReplyInbox();
LeadHeartbeatLoop loop = tickableLoop(herdr, now, rosterBox, inbox, 0, scheduler);
loop.tick(); // opens the idle window
now.addAndGet(TimeUnit.SECONDS.toNanos(400));
loop.tick(); // would INJECT, but the operator is mid-sentence
assertEquals(0, herdr.sentTexts().size(),
"a nudge pastes and submits, so it must not land on a half-typed line");
herdr.paneTail(FakeHerdr.IDLE_PROMPT_CARET);
loop.tick();
assertEquals(1, herdr.sentTexts().size(), "the held nudge lands once the box is empty");
assertTrue(herdr.sentTexts().get(0).contains("Your own context is nearly full"),
"and it still carries the notice the held tick did not spend: " + herdr.sentTexts().get(0));
}
@Test
void anUnreadablePaneHoldsTheHeartbeatNudge() {
var herdr = new FailableHerdrClient(LEAD);
herdr.paneTail("garbled ansi noise with no input box");
var now = new AtomicLong(NOW);
@SuppressWarnings("unchecked")
List<MemberSession>[] rosterBox = new List[]{List.of()};
InMemoryReplyInbox inbox = new InMemoryReplyInbox();
LeadHeartbeatLoop loop = tickableLoop(herdr, now, rosterBox, inbox, 0, scheduler);
loop.tick();
now.addAndGet(TimeUnit.SECONDS.toNanos(400));
loop.tick();
assertEquals(0, herdr.sentTexts().size(),
"a pane whose box cannot be found may be holding a draft");
}
}
@@ -0,0 +1,142 @@
package dev.ltms.fleet.msg;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.inject.Injector;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* A delegation whose message the pane collected itself must reach the same ticket states as one
* typed into the pane.
*/
class MessageServiceInboxDeliveryTest {
/** A pane that collects its own mail. */
private static final String MOD = "term_mod";
/** A pane that does not — the control for every route-specific assertion here. */
private static final String PTY = "term_pty";
private final FakeHerdr herdr = new FakeHerdr();
private final AgentControl agents = new AgentControl(herdr);
private final Rendezvous rendezvous = new Rendezvous();
private final Injector injector = new Injector(agents);
private final InMemoryReplyInbox replyInbox = new InMemoryReplyInbox();
private final MessageService messages = new MessageService(agents, injector, rendezvous, replyInbox);
@BeforeEach
void setUp() {
replyInbox.own(MOD);
replyInbox.own(PTY);
}
/** The messages typed into a pane, in order. A collected message must never appear here. */
@SuppressWarnings("unchecked")
private List<String> typed() {
return herdr.calls.stream()
.filter(c -> c.method().equals("agent.prompt"))
.map(c -> ((Map<String, Object>) c.params()).get("text").toString())
.toList();
}
/** {@code sendAsync} queues on another thread, so wait for the delivery to exist. */
private void awaitWaiting(String target) throws InterruptedException {
long deadline = System.currentTimeMillis() + 2000;
while (!rendezvous.isWaiting(target) && System.currentTimeMillis() < deadline) {
//noinspection BusyWait
Thread.sleep(5);
}
assertTrue(rendezvous.isWaiting(target),
"the delegation to " + target + " should have opened its rendezvous waiter");
}
/** A ticket's terminal state is stamped by the delegating thread, so poll until it settles. */
private MessageService.TaskView awaitSettled(String ticket) throws InterruptedException {
MessageService.TaskView view = null;
long deadline = System.currentTimeMillis() + 2000;
while ((view == null || view.phase() == MessageService.Phase.PENDING)
&& System.currentTimeMillis() < deadline) {
view = messages.poll(ticket);
//noinspection BusyWait
Thread.sleep(5);
}
assertNotNull(view, ticket + " must still be a known ticket");
return view;
}
@Test
void aTicketCollectedByThePaneReachesTheSameStatesAsATypedOne() throws Exception {
// The collecting pane announces itself before anything is delegated to it; the control
// pane never calls fleet_inbox at all.
assertEquals(List.of(), messages.collectInbox(MOD), "nothing is waiting yet");
String collectedTicket = messages.sendAsync(MOD, "long task");
awaitWaiting(MOD);
String typedTicket = messages.sendAsync(PTY, "long task");
awaitWaiting(PTY);
injector.onStatus(MOD, AgentStatus.IDLE); // offers the task for collection
injector.onStatus(PTY, AgentStatus.IDLE); // types the task into the pane
assertEquals(List.of("long task"), typed(),
"only the control pane was typed into; the collecting pane was not");
assertTrue(messages.poll(collectedTicket).detail().contains("queued, not yet delivered"),
"control: an offered-but-uncollected message has not reached its pane");
assertFalse(messages.poll(typedTicket).detail().contains("queued, not yet delivered"),
"control: a typed message has reached its pane");
assertEquals(List.of("long task"), messages.collectInbox(MOD), "the pane collects the task");
injector.onStatus(MOD, AgentStatus.IDLE); // records the delivery
assertFalse(messages.poll(collectedTicket).detail().contains("queued, not yet delivered"),
"a collected message is reported delivered, the same as a typed one");
injector.onStatus(MOD, AgentStatus.WORKING);
injector.onStatus(PTY, AgentStatus.WORKING);
// The reply still arrives through fleet_reply, and lands the same way on both routes.
MessageService.ReplyOutcome collectedReply = messages.reply(MOD, "task done");
MessageService.ReplyOutcome typedReply = messages.reply(PTY, "task done");
assertEquals(typedReply, collectedReply, "both routes resolve their delegation the same way");
assertEquals(MessageService.ReplyOutcome.RESOLVED_SEND, collectedReply);
MessageService.TaskView collectedDone = awaitSettled(collectedTicket);
MessageService.TaskView typedDone = awaitSettled(typedTicket);
assertEquals(MessageService.Phase.DONE, collectedDone.phase(),
"a ticket delivered by collection must not strand at PENDING");
assertEquals(MessageService.Phase.DONE, typedDone.phase(),
"control: the typed route reaches the same terminal state");
assertEquals("task done", collectedDone.reply());
assertEquals(typedDone.replySource(), collectedDone.replySource());
assertEquals(List.of("long task"), typed(),
"the collected delegation ran start to finish without typing into its pane");
}
@Test
void collectingAnInboxReachesOnlyTheCallersOwnMail() throws Exception {
assertEquals(List.of(), messages.collectInbox(MOD));
assertEquals(List.of(), messages.collectInbox(PTY));
messages.sendAsync(MOD, "task for the mod pane");
awaitWaiting(MOD);
messages.sendAsync(PTY, "task for the other pane");
awaitWaiting(PTY);
injector.onStatus(MOD, AgentStatus.IDLE);
injector.onStatus(PTY, AgentStatus.IDLE);
assertEquals(List.of("task for the mod pane"), messages.collectInbox(MOD));
// The control: the other pane's task really was waiting for it, so a collect that
// returned everyone's mail would have shown it on the line above.
assertEquals(List.of("task for the other pane"), messages.collectInbox(PTY));
}
}
@@ -1706,6 +1706,43 @@ class MessageServiceTest {
"the reply completed its own ticket directly and never touched the inbox");
}
/**
* A message still sitting in the injector's queue — never attempted, let alone delivered —
* must not poll as the worker working on it. {@code herdr.agentStatus("working")} supplies the
* target's real, independent live status (busy with something else entirely), while no
* {@code injector.onStatus} call is ever made, so the message is never even attempted.
*/
@Test
void aQueuedButNeverInjectedMessageDoesNotPollAsWorking() throws Exception {
herdr.agentStatus("working");
String ticket = messages.sendAsync(T, "task");
awaitWaiting();
MessageService.TaskView view = messages.poll(ticket);
assertEquals(MessageService.Phase.PENDING, view.phase());
assertFalse(view.detail().contains("worker working"),
"a message never injected must not read as the worker working on it: " + view.detail());
assertTrue(view.detail().contains("queued") && view.detail().contains("not yet delivered"),
"must report the message as queued, not delivered: " + view.detail());
}
/**
* Positive control for the test above: once the message is actually delivered, the poll's
* detail is the plain live-status text again.
*/
@Test
void aDeliveredMessageStillPollsAsWorkerWorking() throws Exception {
herdr.agentStatus("working");
String ticket = messages.sendAsync(T, "task");
awaitWaiting();
injectDelivery();
MessageService.TaskView view = messages.poll(ticket);
assertEquals(MessageService.Phase.PENDING, view.phase());
assertEquals("worker working", view.detail(),
"once actually delivered, the detail reports the worker's live status directly");
}
/**
* fleetd #329 (F1). {@code answer()} completes the async ticket by looking {@code turnId} up in
* {@code asyncTasksByTurn} a SECOND time (the first is at :991, purely to re-register the
@@ -2376,7 +2413,7 @@ class MessageServiceTest {
private PushWiring wireWithPushLoop(int maxReminders, long backoffMs, java.util.function.LongSupplier nowNanos) {
PrimaryRegistry registry = new PrimaryRegistry(null);
registry.recordDelegation(T, LEAD);
FakeHerdr leadHerdr = new FakeHerdr();
FakeHerdr leadHerdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET);
AgentControl leadAgents = new AgentControl(leadHerdr);
var scheduler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
ReplyPushLoop pushLoop = new ReplyPushLoop(registry, leadAgents, inbox, scheduler, maxReminders, backoffMs);
@@ -2413,7 +2450,7 @@ class MessageServiceTest {
java.util.function.LongSupplier nowNanos) {
PrimaryRegistry registry = new PrimaryRegistry(null);
registry.recordDelegation(T, LEAD);
FakeHerdr leadHerdr = new FakeHerdr();
FakeHerdr leadHerdr = new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET);
AgentControl leadAgents = new AgentControl(leadHerdr);
ManualScheduler scheduler = new ManualScheduler();
ReplyPushLoop pushLoop = new ReplyPushLoop(registry, leadAgents, inbox, scheduler, maxReminders, backoffMs);
@@ -2672,7 +2709,8 @@ class MessageServiceTest {
var scheduler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
// A backoff far longer than the test: the schedule is started but no tick ever fires, so
// decide() is read directly and nothing here depends on timing.
ReplyPushLoop pushLoop = new ReplyPushLoop(registry, new AgentControl(new FakeHerdr()), inbox,
ReplyPushLoop pushLoop = new ReplyPushLoop(registry,
new AgentControl(new FakeHerdr().detectionText(FakeHerdr.IDLE_PROMPT_CARET)), inbox,
scheduler, 5, 60_000);
MessageService service = new MessageService(agents, injector, rendezvous, inbox, pushLoop);
try {
@@ -3,6 +3,7 @@ package dev.ltms.fleet.msg;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.herdr.HerdrClient;
import dev.ltms.fleet.herdr.HerdrException;
import dev.ltms.fleet.mcp.PrimaryRegistry;
@@ -1079,6 +1080,76 @@ class ReplyPushLoopTest {
"hitting the ticket reminder cap must count as exhausted");
}
// --- the operator's own prompt box ----------------------------------------------------------
@Test
void aLeadWithUnsubmittedTextInItsPromptBoxIsNotNudged() {
var herdr = new PaneTextHerdrClient(FakeHerdr.DRAFTED_PROMPT_CARET);
agents = new AgentControl(herdr);
inbox.publish(WORKER, "m1", "hello");
var loop = loop(5, 100_000);
loop.onReplyQueued(WORKER);
assertEquals(ReplyPushLoop.Action.WAIT_BUSY, loop.decide(PRIMARY, 0, 0),
"a nudge pastes and submits, so an idle lead mid-sentence must not be nudged");
assertEquals(0, herdr.promptCount(), "nothing reached the pane");
assertFalse(inbox.peek(WORKER).isEmpty(), "and the reply is still waiting to be collected");
}
@Test
void theSameLeadIsNudgedOnceItsPromptBoxIsEmpty() {
var herdr = new PaneTextHerdrClient(FakeHerdr.DRAFTED_PROMPT_CARET);
agents = new AgentControl(herdr);
inbox.publish(WORKER, "m1", "hello");
var loop = loop(5, 100_000);
loop.onReplyQueued(WORKER);
assertEquals(ReplyPushLoop.Action.WAIT_BUSY, loop.decide(PRIMARY, 0, 0));
herdr.paneText(FakeHerdr.IDLE_PROMPT_CARET);
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 0, 0),
"the box emptied, so the held nudge is due");
}
@Test
void anUnrecognisablePaneHoldsTheNudge() {
var herdr = new PaneTextHerdrClient("garbled ansi noise with no input box");
agents = new AgentControl(herdr);
inbox.publish(WORKER, "m1", "hello");
var loop = loop(5, 100_000);
loop.onReplyQueued(WORKER);
assertEquals(ReplyPushLoop.Action.WAIT_BUSY, loop.decide(PRIMARY, 0, 0),
"a pane whose box cannot be found may be holding a draft");
assertEquals(0, herdr.promptCount());
}
@Test
void aFailedPaneReadHoldsTheNudge() {
var herdr = new PaneTextHerdrClient(FakeHerdr.IDLE_PROMPT_CARET).failReads();
agents = new AgentControl(herdr);
inbox.publish(WORKER, "m1", "hello");
var loop = loop(5, 100_000);
loop.onReplyQueued(WORKER);
assertEquals(ReplyPushLoop.Action.WAIT_BUSY, loop.decide(PRIMARY, 0, 0),
"an unreadable box is treated as a draft, never as an empty one");
assertEquals(0, herdr.promptCount());
}
@Test
void aNudgeHeldForADraftIsSentOnALaterTick() throws Exception {
var herdr = new PaneTextHerdrClient(FakeHerdr.DRAFTED_PROMPT_CARET);
agents = new AgentControl(herdr);
loop(5, 50).onTicketTerminal("task-1", WORKER, false);
Thread.sleep(300);
assertEquals(0, herdr.promptCount(), "every tick holds while the operator is typing");
herdr.paneText(FakeHerdr.IDLE_PROMPT_CARET);
assertTrue(herdr.sendLatch.await(3, TimeUnit.SECONDS),
"the nudge lands on the first tick after the box empties");
}
// --- helpers -------------------------------------------------------------------------------
private ReplyPushLoop loop() {
@@ -1097,6 +1168,15 @@ class ReplyPushLoopTest {
return new AgentControl(new FakeHerdrClient(status));
}
/**
* The {@code agent.read} frame every fake here returns: a lead settled at an empty input box. The
* loop reads the box before it nudges, so a fake that answered nothing would read as a pane it
* cannot classify and hold every nudge.
*/
private static JsonNode emptyPromptBoxRead() {
return MAPPER.createObjectNode().set("read", MAPPER.createObjectNode().put("text", FakeHerdr.IDLE_PROMPT_CARET));
}
/** Non-recording (single-threaded) fake — safe for decide() tests. */
private static final class FakeHerdrClient implements HerdrClient {
private final String agentStatus;
@@ -1113,6 +1193,9 @@ class ReplyPushLoopTest {
.put("terminal_id", PRIMARY)
.put("agent_status", agentStatus));
}
if ("agent.read".equals(method)) {
return emptyPromptBoxRead();
}
return MAPPER.createObjectNode();
}
@@ -1142,6 +1225,9 @@ class ReplyPushLoopTest {
calls.add(Map.entry(method, params));
sendLatch.countDown();
}
if ("agent.read".equals(method)) {
return emptyPromptBoxRead();
}
return MAPPER.createObjectNode();
}
@@ -1179,6 +1265,9 @@ class ReplyPushLoopTest {
if ("agent.prompt".equals(method)) {
sendLatch.countDown();
}
if ("agent.read".equals(method)) {
return emptyPromptBoxRead();
}
return MAPPER.createObjectNode();
}
@@ -1216,6 +1305,9 @@ class ReplyPushLoopTest {
calls.add(Map.entry(method, params));
sendLatch.countDown();
}
if ("agent.read".equals(method)) {
return emptyPromptBoxRead();
}
return MAPPER.createObjectNode();
}
@@ -1266,6 +1358,9 @@ class ReplyPushLoopTest {
promptTargets.add(String.valueOf(p.get("target")));
sendLatch.countDown();
}
if ("agent.read".equals(method)) {
return emptyPromptBoxRead();
}
return MAPPER.createObjectNode();
}
@@ -1316,6 +1411,9 @@ class ReplyPushLoopTest {
if ("agent.prompt".equals(method)) {
promptTargets.add(String.valueOf(p.get("target")));
}
if ("agent.read".equals(method)) {
return emptyPromptBoxRead();
}
return MAPPER.createObjectNode();
}
@@ -1363,6 +1461,9 @@ class ReplyPushLoopTest {
promptTargets.add(String.valueOf(p.get("target")));
sendLatch.countDown();
}
if ("agent.read".equals(method)) {
return emptyPromptBoxRead();
}
return MAPPER.createObjectNode();
}
@@ -1374,4 +1475,60 @@ class ReplyPushLoopTest {
public void close() {
}
}
/**
* Thread-safe fake that always reports {@code idle} and serves a mutable pane tail, so a test can
* change what the lead's input box holds between ticks. Records every {@code agent.prompt}.
*/
private static final class PaneTextHerdrClient implements HerdrClient {
private final List<Map.Entry<String, Object>> prompts =
Collections.synchronizedList(new ArrayList<>());
private volatile String paneText;
private volatile boolean failReads = false;
volatile CountDownLatch sendLatch = new CountDownLatch(1);
PaneTextHerdrClient(String paneText) {
this.paneText = paneText;
}
void paneText(String text) {
this.paneText = text;
}
PaneTextHerdrClient failReads() {
this.failReads = true;
return this;
}
int promptCount() {
return prompts.size();
}
@Override
public JsonNode call(String method, Object params) {
if ("agent.get".equals(method)) {
return MAPPER.createObjectNode()
.set("agent", MAPPER.createObjectNode()
.put("terminal_id", PRIMARY)
.put("agent_status", "idle"));
}
if ("agent.read".equals(method)) {
if (failReads) {
throw new HerdrException("herdr socket read timed out");
}
return MAPPER.createObjectNode()
.set("read", MAPPER.createObjectNode().put("text", paneText));
}
if ("agent.prompt".equals(method)) {
prompts.add(Map.entry(method, params));
sendLatch.countDown();
}
return MAPPER.createObjectNode();
}
@Override
public void close() {
}
}
}
@@ -91,8 +91,13 @@ class FleetAppAuthTest {
MessageService messages = new MessageService(agents, injector, new Rendezvous());
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
// term_a is the only herdr-owned pane this fixture's PID can resolve to (FakeHerdr's canned
// pane list), and this helper's own contract above says that pane is the worker -- so it
// must be recognised as a live spawned member here, the same way a real roster would,
// rather than falling through to the observer floor.
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, tokenMode, token,
Map::of, new MemberRegistry(null));
Map::of, new MemberRegistry(null), t -> "term_a".equals(t) ? MemberRole.DEV : null,
Map::of);
metrics = FleetMetrics.create(sessions, new dev.ltms.fleet.msg.InMemoryReplyInbox());
app = new FleetApp(herdr, workers, sessions, messages, sessions.asPresence(), null,
@@ -689,6 +694,27 @@ class FleetAppAuthTest {
assertEquals(403, toSpawnedMembersTerminal.statusCode(), toSpawnedMembersTerminal.body());
}
/**
* The REST route must give the same answer as MCP for an observer: pid 9001 resolves to
* "term_shell", a herdr pane recognised as no configured role, so the real
* {@link CallerResolver#observerSendTarget()} classifier reaches the known lead and refuses
* the known collaborator -- over the route, not just the unit-level classifier, so a grant
* covering only MCP cannot leave this one behind.
*/
@Test
void anObserverMaySendToAKnownLeadButNotToAKnownCollaboratorOverRest() throws Exception {
int port = startWithRealClassifier(9001L,
Map.of("term_lead_known", "lead-x"), Map.of("term_collab_known", "ops2"));
HttpResponse<String> toLead = send(port, "POST", "/sessions/term_lead_known/message",
"{\"content\":\"hi\",\"wait\":false}", null);
assertEquals(202, toLead.statusCode(), toLead.body());
HttpResponse<String> toCollaborator = send(port, "POST", "/sessions/term_collab_known/message",
"{\"content\":\"hi\",\"wait\":false}", null);
assertEquals(403, toCollaborator.statusCode(), toCollaborator.body());
}
/**
* As {@link #start}, but with explicit lead/collaborator maps and no spawned-member roster, so
* a test can wire the real {@link CallerResolver#knownLeadOrCollaborator()} classifier instead
@@ -223,6 +223,37 @@ class FleetAppTest {
assertTrue(body.has("detail"), res.body());
}
@Test
void agentsReportsTheAgentsTabLabel() throws Exception {
FakeHerdr herdr = new FakeHerdr().withTab("w2", "w2:t7", "trinotes");
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
HttpResponse<String> res = req(port, "GET", "/agents");
assertEquals(200, res.statusCode(), res.body());
JsonNode agents = mapper.readTree(res.body()).get("agents");
assertEquals(1, agents.size());
assertEquals("sess-1111", agents.get(0).get("sessionId").asText());
assertEquals("trinotes", agents.get(0).get("label").asText());
}
/**
* The tab-label scan ({@code workspace.list}/{@code tab.list}) is decoration on top of
* {@code workers.list()}'s own agent roster, so its failure must not cost that roster: a row
* reports a {@code null} label instead, never the {@code herdr_error} envelope.
*/
@Test
void agentsStillReportsTheRosterWhenTheLabelScanFails() throws Exception {
FakeHerdr herdr = new FakeHerdr().workspaceListFailsWith("unavailable");
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
HttpResponse<String> res = req(port, "GET", "/agents");
assertEquals(200, res.statusCode(), res.body());
JsonNode agents = mapper.readTree(res.body()).get("agents");
assertEquals(1, agents.size());
assertEquals("sess-1111", agents.get(0).get("sessionId").asText());
assertTrue(agents.get(0).get("label").isNull(), "a failed label scan must report a null label, not fail the roster: " + res.body());
}
@Test
void spawnWorkerLandsInOwnTabInWorkerSpaceAndInjectsBaseUrl() throws Exception {
FakeHerdr herdr = new FakeHerdr();
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "fleet",
"description": "Make a project fleet-ready: mount the fleetd MCP gateway and set up standard Claude Code settings so this session can orchestrate a fleet of delegated workers. Lead-side only — member skills and agents travel in the worktree. Ships no credentials.",
"version": "0.2.0",
"description": "Set up standard Claude Code settings so this session can orchestrate a fleet of delegated workers, and run the fleet mod for cross-session messaging. Lead-side only — member skills and agents travel in the worktree, and mounting the fleetd MCP gateway is now the instance's or the project's job, not this plugin's. Ships no credentials.",
"version": "0.3.0",
"author": {
"name": "LTMS"
},
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"fleet": {
"type": "http",
"url": "${FLEETD_MCP_URL}"
}
}
}
+35 -19
View File
@@ -1,7 +1,7 @@
# fleet (Claude Code plugin)
Makes a project **fleet-ready**: mounts the `fleetd` MCP gateway and applies standard Claude Code
settings, so the session can orchestrate a fleet of delegated workers.
Makes a project **fleet-ready**: applies standard Claude Code settings and runs the fleet mod, so
the session can orchestrate a fleet of delegated workers.
**This plugin ships no credentials.** Every secret is referenced by environment-variable *name*;
the values stay with the user. Nothing the plugin writes is unsafe to commit.
@@ -22,9 +22,10 @@ Member-facing assets travel in the worktree, not in this plugin. See fleetd #362
## What it is not
The plugin is the **client-side setup**, not the bridge. `fleetd` is a separate daemon and `herdr`
is a separate PTY multiplexer, each with its own lifecycle and install. The plugin mounts an
already-running daemon and tells you what is missing when one isn't there — it deliberately does
not try to install system services on your behalf.
is a separate PTY multiplexer, each with its own lifecycle and install, and the plugin does not try
to install either on your behalf. It also does not mount the daemon for you — mounting is the
instance's or the project's own `.mcp.json`, and `/fleet:setup` is the one thing in this plugin that
still helps with that (it writes the project-level entry).
## Install
@@ -33,11 +34,20 @@ not try to install system services on your behalf.
/plugin install fleet@fleetd
```
Export the gateway URL — the plugin mounts `${FLEETD_MCP_URL}`, not a hardcoded address, so one
plugin serves hosts that run the daemon on different ports:
Mount the daemon yourself — this plugin carries no mount of its own. Either add the entry below to
your Claude Code instance's own `.claude.json`, so every project you open there gets it, or run
`/fleet:setup` in the project you want to onboard, which writes the same entry into that project's
`.mcp.json`:
```shell
export FLEETD_MCP_URL=http://127.0.0.1:8765/mcp
```json
{
"mcpServers": {
"fleet": {
"type": "http",
"url": "http://127.0.0.1:8765/mcp"
}
}
}
```
Then, in the project you want to onboard:
@@ -50,18 +60,24 @@ Then, in the project you want to onboard:
| Component | Effect |
|---|---|
| `.mcp.json` | mounts `fleet` at `${FLEETD_MCP_URL}` for any session with the plugin enabled |
| `skills/setup` | `/fleet:setup` — preflight, project settings, credential guidance, and verification |
| `skills/setup` | `/fleet:setup` — preflight, project settings, credential guidance, and verification. Also the only thing in this plugin that helps mount `fleet`: it writes the project `.mcp.json` entry shown above. |
| `hooks/register.js` (the fleet mod) | Cross-account session messaging while the plugin is enabled: `/fleet-peers`, `/fleet-mail`, `/fleet-whoami`, and a background poll that delivers mail fleetd queued for this pane. A spawned worker or architect skips that poll, because it already gets its brief pasted into its pane. |
The server is named **`fleet`** on purpose: that is `PeerLauncher.MCP_MOUNT_NAME` in the daemon and
the name a spawned member's own mount carries. Version 0.1.0 named it `fleetd`, which produced two
mounts of one daemon for anyone who also had a project-level `.mcp.json`. Upgrading from 0.1.0 is a
**breaking change** — a project that pre-allowed `mcp__fleetd__fleet_whoami` in
`.claude/settings.json` must be updated to `mcp__fleet__*`.
Whichever file mounts the daemon, name the server **`fleet`**. That is `PeerLauncher.MCP_MOUNT_NAME`
in the daemon, the name a spawned member's own mount carries, and the name the `mcp__fleet__*`
role heuristic in `CLAUDE.md` keys on.
Because the plugin carries its own `.mcp.json`, an installed plugin needs no project-level MCP
file at all. The setup skill writes one only when you want the mount to work *without* the plugin —
for teammates who haven't installed it, or for CI.
## Upgrading from 0.2.0 — breaking
The plugin no longer mounts the daemon. It used to carry its own `.mcp.json`, pointed at
`${FLEETD_MCP_URL}`, and that file is gone. The mod still reads `FLEETD_MCP_URL`, but only as an
optional override of the address it calls (default `http://127.0.0.1:8765/mcp`). Mount `fleet`
yourself: add the entry under **Install** above to your instance's `.claude.json` or to the
project's own `.mcp.json`, by hand or with `/fleet:setup`.
Version 0.1.0 named the mounted server `fleetd`, which produced two mounts of one daemon for
anyone who also had a project-level `.mcp.json`. A project that pre-allowed
`mcp__fleetd__fleet_whoami` in `.claude/settings.json` must be updated to `mcp__fleet__*`.
## Verifying a setup
+4
View File
@@ -0,0 +1,4 @@
{
"description": "fleet mod: cross-account session messaging and PTY-free delivery",
"modules": ["./register.js"]
}
+281
View File
@@ -0,0 +1,281 @@
// fleet mod — session messaging for the claude-bridge fleet.
//
// $.store is kept under CLAUDE_CONFIG_DIR, so /fleet-peers and /fleet-mail reach
// only sessions that share this session's config dir. fleetd names a caller by
// its pane, not its account, so /fleet-whoami and anything else sent through
// fleetTool reach the fleet from either account.
//
// Delivery uses $.prompt.submit, so nothing is typed into a pane and no prompt
// box is read.
//
// There are two inboxes. The $.store one carries /fleet-mail between sessions
// that share this config dir. fleet_inbox carries what the fleet queued for
// this pane, from any account, and calling it is also what tells fleetd to
// queue here rather than type into the terminal.
const PRESENCE_PREFIX = 'presence:'
const INBOX_PREFIX = 'inbox:'
const PRESENCE_REFRESH_MS = 15_000
const INBOX_POLL_MS = 3_000
// fleetd stops queueing for a pane that goes quiet, so this must stay well
// under the window the daemon allows between calls.
const FLEETD_INBOX_POLL_MS = 3_000
// A session whose presence row is older than this is treated as gone. It must
// exceed PRESENCE_REFRESH_MS by enough that one missed refresh is not a death.
const PRESENCE_STALE_MS = 60_000
const MAX_INBOX = 50
/** The store key holding one session's queued messages. */
function inboxKey(sessionId) {
return INBOX_PREFIX + sessionId
}
/** The store key holding one session's presence row. */
function presenceKey(sessionId) {
return PRESENCE_PREFIX + sessionId
}
/**
* Append one message to a target's inbox.
*
* $.store has no compare-and-swap, so two senders writing in the same instant
* can lose a message. Callers that need delivery confirmed should read the
* inbox back.
*/
async function deliver($, target, message) {
const key = inboxKey(target)
const queued = (await $.store.get(key)) || []
queued.push(message)
// Keep the newest: an unread inbox must not grow without bound.
const kept = queued.slice(-MAX_INBOX)
await $.store.set(key, kept)
return kept.length
}
/** Every session that refreshed its presence row recently, newest first. */
async function livePeers($, now) {
const keys = await $.store.keys()
const rows = []
for (const key of keys) {
if (!key.startsWith(PRESENCE_PREFIX)) continue
const row = await $.store.get(key)
if (!row || typeof row.at !== 'number') continue
if (now - row.at > PRESENCE_STALE_MS) continue
rows.push(row)
}
rows.sort((a, b) => b.at - a.at)
return rows
}
const FLEETD_MCP_DEFAULT = 'http://127.0.0.1:8765/mcp'
const MCP_HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }
// The status fleetd answers, with "Session not found", for an Mcp-Session-Id it no longer holds.
const MCP_SESSION_GONE = 404
// The MCP session every call below shares, and the URL it was opened against. fleetd keeps a
// server-side session per initialize and drops it only on a DELETE, so one initialize per call
// would leave a session behind every time.
let mcpSessionId = null
let fleetdUrl = FLEETD_MCP_DEFAULT
/**
* Open an MCP session on the local fleetd and hold it for later calls.
*
* Cleared first, so a failure here leaves no dead id behind for the next call to reuse. Reads
* FLEETD_MCP_URL fresh on every open, so a session opened after the daemon moves uses the new
* address.
*/
async function openFleetSession($) {
mcpSessionId = null
fleetdUrl = (await $.env.get('FLEETD_MCP_URL')) || FLEETD_MCP_DEFAULT
const init = await $.http.fetch(fleetdUrl, {
method: 'POST',
headers: MCP_HEADERS,
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'fleet-mod', version: '0' } },
}),
})
if (!init.ok) throw new Error('fleetd initialize failed with status ' + init.status)
const opened = init.headers['mcp-session-id']
await $.http.fetch(fleetdUrl, {
method: 'POST',
headers: { ...MCP_HEADERS, 'Mcp-Session-Id': opened },
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
})
mcpSessionId = opened
}
/** Send one tools/call on the session this mod holds, and return the raw HTTP answer. */
function sendFleetToolCall($, tool, args) {
return $.http.fetch(fleetdUrl, {
method: 'POST',
headers: { ...MCP_HEADERS, 'Mcp-Session-Id': mcpSessionId },
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: tool, arguments: args } }),
})
}
/**
* Call one fleet_* tool on the local fleetd, and return its text result.
*
* fleetd names the caller from the TCP connection on every request, not from the MCP session, so
* the answer is about this session's own pane whichever Claude account the session runs on, and
* reusing one session never changes whose call it is.
*/
async function fleetTool($, tool, args) {
if (mcpSessionId === null) await openFleetSession($)
let call = await sendFleetToolCall($, tool, args)
if (call.status === MCP_SESSION_GONE) {
// A daemon restart drops every session it held. Open a new one and retry once.
await openFleetSession($)
call = await sendFleetToolCall($, tool, args)
}
if (!call.ok) throw new Error('fleetd ' + tool + ' failed with status ' + call.status)
// A tool call answers as a server-sent event: the JSON is on the data: line.
const line = call.text.split('\n').find((l) => l.startsWith('data:'))
const body = JSON.parse(line ? line.slice(5) : call.text)
if (body.error) throw new Error(body.error.message)
return body.result.content.map((c) => c.text).join('\n')
}
export function register(on) {
on('session.start', async ($, e, next) => {
const self = await $.session.id()
// Announce before the first refresh is due, or a session shorter than one
// refresh interval never appears to its peers at all.
await $.store.set(presenceKey(self), {
sessionId: self,
cwd: await $.session.cwd(),
at: await $.clock.now(),
})
// Keep announcing: a row that stops being refreshed is how another session
// learns this one is gone.
$.clock.every(PRESENCE_REFRESH_MS, async () => {
const now = await $.clock.now()
await $.store.set(presenceKey(self), {
sessionId: self,
cwd: await $.session.cwd(),
at: now,
})
})
// Collect this session's mail and hand it to Claude. $.prompt.submit waits
// for the session to be idle, so this never lands mid-turn.
$.clock.every(INBOX_POLL_MS, async () => {
const key = inboxKey(self)
const queued = (await $.store.get(key)) || []
if (queued.length === 0) return
await $.store.set(key, [])
for (const message of queued) {
await $.prompt.submit({
text: 'Message from fleet session ' + message.from + ':\n\n' + message.text,
})
}
})
// A spawned worker or architect already gets its brief pasted into its pane, so this
// poll would be a second, redundant delivery path for it. Every other role collects
// its own mail through this poll.
let role = null
// Collect what the fleet queued for this pane and hand each message to
// Claude. Every call also renews fleetd's record that this pane collects its
// own mail, so an empty answer still has to be asked for.
$.clock.every(FLEETD_INBOX_POLL_MS, async () => {
if (role === null) {
try {
role = JSON.parse(await fleetTool($, 'fleet_whoami', {})).role
} catch {
// A daemon that is down, or a pane fleetd cannot place, is the ordinary
// case on a host with no fleet running. Retry on the next tick.
return
}
}
if (role === 'worker' || role === 'architect') return
let collected
try {
collected = JSON.parse(await fleetTool($, 'fleet_inbox', {}))
} catch {
// The timer survives a throw, so this only keeps every tick from
// writing an error to the debug log.
return
}
for (const text of collected.messages || []) {
await $.prompt.submit({ text: 'Message from the fleet, via fleetd:\n\n' + text })
}
})
await $.command.register({
name: 'fleet-peers',
description: 'List fleet sessions on this machine, including other accounts',
})
await $.command.register({
name: 'fleet-whoami',
description: 'Show who fleetd says this session is',
})
await $.command.register({
name: 'fleet-mail',
description: 'Send a message to a fleet session on this machine',
argumentHint: '<sessionId> <text>',
// Runs even while Claude is working, so a correction is never queued
// behind the turn it is meant to correct.
immediate: true,
})
return next(e)
})
on('command.run', { command: 'fleet-peers' }, async ($) => {
const now = await $.clock.now()
const self = await $.session.id()
const peers = await livePeers($, now)
if (peers.length === 0) return { text: 'No fleet sessions have announced themselves yet.' }
const lines = peers.map((p) => {
const age = Math.round((now - p.at) / 1000)
const mark = p.sessionId === self ? ' (this session)' : ''
return p.sessionId + ' ' + p.cwd + ' seen ' + age + 's ago' + mark
})
return { text: 'Fleet sessions on this machine:\n' + lines.join('\n') }
})
on('command.run', { command: 'fleet-mail' }, async ($, e) => {
const args = (e.args || '').trim()
const split = args.indexOf(' ')
if (split < 1) return { text: 'Usage: /fleet-mail <sessionId> <text>' }
const target = args.slice(0, split)
const text = args.slice(split + 1).trim()
if (text === '') return { text: 'Usage: /fleet-mail <sessionId> <text>' }
const now = await $.clock.now()
const peers = await livePeers($, now)
if (!peers.some((p) => p.sessionId === target)) {
return { text: 'No live fleet session ' + target + '. Run /fleet-peers.' }
}
const self = await $.session.id()
const depth = await deliver($, target, { from: self, text: text, at: now })
return { text: 'Queued for ' + target + ' (' + depth + ' in its inbox).' }
})
on('command.run', { command: 'fleet-whoami' }, async ($) => {
try {
return { text: 'fleetd says: ' + (await fleetTool($, 'fleet_whoami', {})) }
} catch (err) {
return { text: 'fleetd unreachable: ' + err.message }
}
})
// Record what arrives over Claude Code's own channel, so a message delivered
// by the fleet and one delivered by SendMessage can be told apart.
//
// This hook gates delivery, so it must never decide the message's fate. The
// .catch handler passes the message on when the logging above throws.
on('session.receive', async ($, e, next) => {
$.ui.log('fleet: inbound ' + (e.origin && e.origin.kind) + ', ' + String(e.text).length + ' chars')
return next(e)
}).catch(async ($, e, next) => {
if (next.called) return undefined
return next(e)
})
}
+6 -12
View File
@@ -36,16 +36,11 @@ a time.
command -v herdr && herdr --version 2>&1 | head -1 || echo "MISSING: herdr"
command -v ccs && ccs version 2>&1 | head -1 || echo "MISSING: ccs (needed for worker profiles)"
command -v codex && codex --version 2>&1 | head -1 || echo "absent: codex (optional)"
curl -s -m 5 "${FLEETD_MCP_URL%/mcp}/healthz" 2>/dev/null \
|| curl -s -m 5 http://127.0.0.1:8765/healthz \
|| echo "MISSING: fleetd daemon is not reachable"
[ -n "$FLEETD_MCP_URL" ] && echo "FLEETD_MCP_URL is set" || echo "MISSING: FLEETD_MCP_URL"
curl -s -m 5 http://127.0.0.1:8765/healthz || echo "MISSING: fleetd daemon is not reachable"
```
**`FLEETD_MCP_URL` is required.** The plugin's own `.mcp.json` mounts `${FLEETD_MCP_URL}` rather
than a hardcoded address, so one plugin can serve hosts that run the daemon on different ports. If
it is unset the mount does not resolve. The usual value is `http://127.0.0.1:8765/mcp`; tell the
user to export it, do not write it into a file for them.
The usual address is `http://127.0.0.1:8765`. If this daemon runs elsewhere, use that address
instead wherever this skill writes `http://127.0.0.1:8765/mcp` below.
A healthy daemon answers with its status **and the herdr protocol it negotiated**:
@@ -95,10 +90,9 @@ If `.mcp.json` already exists, add only the `fleet` key and leave every other se
If a `fleet` entry is already there with a different URL, **ask** rather than assuming yours is
right — a non-default port usually means a deliberate second daemon.
> **If this plugin is installed, you can skip this step entirely.** The plugin ships its own
> `.mcp.json`, so `fleetd` is already mounted for any session with the plugin enabled. Write the
> project-level file only when the user wants the mount to work *without* the plugin — for
> teammates who have not installed it, or for CI.
This write is the only way this plugin helps mount `fleet` — the plugin carries no mount of its
own. A session that wants the mount without running this skill can instead add the same entry to
its own Claude Code instance's `.claude.json`.
**Before writing it, settle whether `.mcp.json` is committed here:**
+490
View File
@@ -0,0 +1,490 @@
import { expect, mock, test } from 'claude-code/testing'
// The store-backed tests write the row another session would write, because a
// test cannot start a second session.
const PEER = 'peer-session'
const SELF = 'this-session'
// A fixed clock keeps the staleness arithmetic exact.
const NOW = 1_700_000_000_000
/**
* Answer the mods API calls the harness has no implementation for, and stand in
* for Claude Code's own behaviour beneath the mod's gating hooks.
*
* Returns the map backing $.store. The harness puts no `store` namespace on the
* test's own `$`, so a test seeds and inspects the carrier through this map.
*/
function stubEngine(on: any): Map<string, any> {
const store = new Map<string, any>()
on('store.get', (_$: any, e: any) => ({ value: store.get(e.key) }))
on('store.set', (_$: any, e: any) => {
store.set(e.key, e.value)
return { value: undefined }
})
on('store.delete', (_$: any, e: any) => {
store.delete(e.key)
return { value: undefined }
})
on('store.keys', () => ({ value: [...store.keys()] }))
on('clock.now', () => ({ value: NOW }))
on('session.id', () => ({ value: SELF }))
on('session.cwd', () => ({ value: '/Users/x/claude-bridge' }))
on('ui.log', () => ({ value: undefined }))
on('prompt.submit', () => ({ value: undefined }))
on('env.get', () => ({ value: undefined }))
// Claude Code's own delivery, which the mod's receive hook must reach.
on('session.receive', (_$: any, e: any) => e)
return store
}
/** The presence row a session on the other account would write. */
function announce(store: Map<string, any>, sessionId: string, cwd: string, at: number) {
store.set('presence:' + sessionId, { sessionId, cwd, at })
}
test('/fleet-peers lists a session that announced itself', async ($, on) => {
const store = stubEngine(on)
announce(store, PEER, '/Users/x/work-repo', NOW)
const answer = await $.command.run({ command: 'fleet-peers', args: '' })
expect(answer.text).toContain(PEER)
expect(answer.text).toContain('/Users/x/work-repo')
})
test('/fleet-peers hides a session whose presence row went stale', async ($, on) => {
const store = stubEngine(on)
// One second past the 60s staleness cut.
announce(store, PEER, '/Users/x/work-repo', NOW - 61_000)
const answer = await $.command.run({ command: 'fleet-peers', args: '' })
expect(answer.text).not.toContain(PEER)
})
test('/fleet-peers keeps a row one second inside the staleness cut', async ($, on) => {
// The positive control for the test above: without this, a bug that hid
// every row would still satisfy that assertion.
const store = stubEngine(on)
announce(store, PEER, '/Users/x/work-repo', NOW - 59_000)
const answer = await $.command.run({ command: 'fleet-peers', args: '' })
expect(answer.text).toContain(PEER)
})
test('/fleet-mail queues a message in the target session inbox', async ($, on) => {
const store = stubEngine(on)
announce(store, PEER, '/Users/x/work-repo', NOW)
const answer = await $.command.run({
command: 'fleet-mail',
args: PEER + ' rebasing on main is safe now',
})
expect(answer.text).toContain('Queued for ' + PEER)
const inbox = store.get('inbox:' + PEER)
expect(inbox.length).toBe(1)
expect(inbox[0].text).toBe('rebasing on main is safe now')
expect(inbox[0].from).toBe(SELF)
})
test('a second message appends rather than replacing the first', async ($, on) => {
const store = stubEngine(on)
announce(store, PEER, '/Users/x/work-repo', NOW)
await $.command.run({ command: 'fleet-mail', args: PEER + ' first' })
await $.command.run({ command: 'fleet-mail', args: PEER + ' second' })
const inbox = store.get('inbox:' + PEER)
expect(inbox.length).toBe(2)
expect(inbox[0].text).toBe('first')
expect(inbox[1].text).toBe('second')
})
test('/fleet-mail refuses a target that never announced itself', async ($, on) => {
const store = stubEngine(on)
const answer = await $.command.run({ command: 'fleet-mail', args: 'ghost-session hello' })
expect(answer.text).toContain('No live fleet session ghost-session')
// Nothing may be queued for a session we could not confirm.
expect(store.get('inbox:ghost-session')).toBe(undefined)
})
test('/fleet-mail rejects input with no message text', async ($, on) => {
const store = stubEngine(on)
announce(store, PEER, '/Users/x/work-repo', NOW)
for (const args of ['', PEER, PEER + ' ']) {
const answer = await $.command.run({ command: 'fleet-mail', args })
expect(answer.text).toContain('Usage: /fleet-mail')
}
expect(store.get('inbox:' + PEER)).toBe(undefined)
})
test('an inbound peer message is passed on, not consumed', async ($, on) => {
// A receive hook that withheld a message would silently break Claude Code's
// own channel, so assert this mod stays transparent.
stubEngine(on)
const result = await $.session.receive({
text: 'from the other session',
origin: { kind: 'peer' },
})
expect(result?.consumed).toBe(undefined)
})
/** Answer fleetd's three MCP requests the way the live daemon does. */
function stubFleetd(on: any, toolText: string, seen: any[]) {
on('http.fetch', (_$: any, e: any) => {
const body = JSON.parse(e.init.body)
seen.push({ method: body.method, session: e.init.headers['Mcp-Session-Id'] })
if (body.method === 'initialize') {
return { value: { ok: true, status: 200, headers: { 'mcp-session-id': 'sid-1' }, text: '{}' } }
}
if (body.method === 'tools/call') {
const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text: toolText }] } }
return { value: { ok: true, status: 200, headers: {}, text: 'event: message\ndata: ' + JSON.stringify(result) + '\n' } }
}
return { value: { ok: true, status: 202, headers: {}, text: '' } }
})
}
test('/fleet-whoami reads the tool result out of the event stream', async ($, on) => {
stubEngine(on)
const seen: any[] = []
stubFleetd(on, '{"role":"observer","sessionId":"term_x"}', seen)
const answer = await $.command.run({ command: 'fleet-whoami', args: '' })
expect(answer.text).toBe('fleetd says: {"role":"observer","sessionId":"term_x"}')
// The session id from initialize must ride on every later request.
expect(seen.map((r) => r.method)).toEqual(['initialize', 'notifications/initialized', 'tools/call'])
expect(seen[2].session).toBe('sid-1')
})
test('/fleet-whoami reports a daemon that is down instead of throwing', async ($, on) => {
stubEngine(on)
on('http.fetch', () => ({ value: { ok: false, status: 503, headers: {}, text: '' } }))
const answer = await $.command.run({ command: 'fleet-whoami', args: '' })
expect(answer.text).toBe('fleetd unreachable: fleetd initialize failed with status 503')
})
/**
* The session.start hook's own calls, for a test that fires it. Kept apart from stubEngine
* because a timer test drives $.clock through mock.clock(on) instead of a fixed clock.now.
*/
function stubSessionStart(on: any, submitted: string[]): Map<string, any> {
const store = new Map<string, any>()
on('store.get', (_$: any, e: any) => ({ value: store.get(e.key) }))
on('store.set', (_$: any, e: any) => {
store.set(e.key, e.value)
return { value: undefined }
})
on('store.keys', () => ({ value: [...store.keys()] }))
on('session.id', () => ({ value: SELF }))
on('session.cwd', () => ({ value: '/Users/x/claude-bridge' }))
on('command.register', () => ({ value: undefined }))
on('ui.log', () => ({ value: undefined }))
on('env.get', () => ({ value: undefined }))
// The engine skips a prompt.submit hook that answers anything but { text } or { drop }, and
// the mod's callback then throws, so this must hand the text straight back.
on('prompt.submit', (_$: any, e: any) => {
submitted.push(e.text)
return { text: e.text }
})
on('session.start', (_$: any, e: any) => e)
return store
}
/**
* Answer fleetd's MCP requests, with the tool result read fresh on every call.
*
* `fetches` collects every method sent, with a tools/call entry naming its tool (such as
* `tools/call:fleet_inbox`), and `sessions` the Mcp-Session-Id of each tools/call. Each
* initialize hands out the next id, so a reused session and a reopened one differ.
* `sessionGone` makes a tools/call answer the way fleetd answers for a session id it no
* longer holds. `whoamiText` answers a `fleet_whoami` call apart from `toolText`, which
* answers every other tool.
*/
function stubFleetdDynamic(
on: any,
toolText: () => string,
fetches: string[],
sessions: string[] = [],
sessionGone: () => boolean = () => false,
whoamiText: () => string = () => '{"role":"primary"}',
) {
let opened = 0
on('http.fetch', (_$: any, e: any) => {
const body = JSON.parse(e.init.body)
if (body.method === 'initialize') {
fetches.push(body.method)
opened += 1
return { value: { ok: true, status: 200, headers: { 'mcp-session-id': 'sid-' + opened }, text: '{}' } }
}
if (body.method === 'tools/call') {
fetches.push(body.method + ':' + body.params.name)
sessions.push(e.init.headers['Mcp-Session-Id'])
if (sessionGone()) {
const gone = '{"jsonRpcError":{"code":-32603,"message":"Session not found"}}'
return { value: { ok: false, status: 404, headers: {}, text: gone } }
}
const text = body.params.name === 'fleet_whoami' ? whoamiText() : toolText()
const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text }] } }
return { value: { ok: true, status: 200, headers: {}, text: 'data: ' + JSON.stringify(result) + '\n' } }
}
fetches.push(body.method)
return { value: { ok: true, status: 202, headers: {}, text: '' } }
})
}
/** How many of `fetches` were an initialize. */
function initializes(fetches: string[]): number {
return fetches.filter((m) => m === 'initialize').length
}
test('the fleetd inbox poll submits each collected message and names the sender', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
let inbox = { sessionId: 'term_self', count: 0, messages: [] as string[] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), [])
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
// An empty inbox is the ordinary answer and must submit nothing.
await clock.advance(3_000)
expect(submitted.length).toBe(0)
// The control for the line above: the same timer, the same stubs, with mail waiting.
inbox = { sessionId: 'term_self', count: 2, messages: ['rebase is safe now', 'build is green'] }
await clock.advance(3_000)
expect(submitted.length).toBe(2)
expect(submitted[0]).toContain('rebase is safe now')
expect(submitted[0]).toContain('fleetd')
expect(submitted[1]).toContain('build is green')
})
test('a collected message is not submitted a second time', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
// fleetd removes a message when it hands it over, so the next poll answers empty.
let inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), [])
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
expect(submitted.length).toBe(1) // control: the first poll really did deliver it
inbox = { sessionId: 'term_self', count: 0, messages: [] }
await clock.advance(3_000)
expect(submitted.length).toBe(1)
})
test('a fleetd that is down leaves the poll timer running', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
on('http.fetch', (_$: any, e: any) => {
fetches.push(JSON.parse(e.init.body).method)
return { value: { ok: false, status: 503, headers: {}, text: '' } }
})
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
const afterFirst = fetches.length
expect(afterFirst).toBeGreaterThan(0)
expect(submitted.length).toBe(0)
// A throw out of the timer would stop it, so a second tick that still reaches fleetd is the
// positive control for the assertion above: the timer survived the failure.
await clock.advance(3_000)
expect(fetches.length).toBeGreaterThan(afterFirst)
expect(submitted.length).toBe(0)
})
test('a second inbox poll reuses the first MCP session', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
const sessions: string[] = []
const inbox = { sessionId: 'term_self', count: 0, messages: [] as string[] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, sessions)
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
await clock.advance(3_000)
// The first poll also reads the role once, so it makes two tools/call (whoami, then
// inbox); the second poll already knows the role and makes only one (inbox).
expect(sessions.length).toBe(3) // control: all three calls really reached fleetd
expect(initializes(fetches)).toBe(1)
expect(sessions.every((s) => s === sessions[0])).toBe(true)
})
test('a session fleetd no longer holds is opened again and the call retried', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
const sessions: string[] = []
let gone = false
const inbox = { sessionId: 'term_self', count: 1, messages: ['the daemon restarted'] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, sessions, () => {
// Only the first call after the flag is set is refused; the retry succeeds.
const refuse = gone
gone = false
return refuse
})
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
// The control: an ordinary poll opens one session and needs no second one.
await clock.advance(3_000)
expect(initializes(fetches)).toBe(1)
expect(submitted.length).toBe(1)
gone = true
await clock.advance(3_000)
expect(initializes(fetches)).toBe(2)
expect(sessions[sessions.length - 1]).toBe('sid-2')
expect(submitted.length).toBe(2)
})
/** How many of `fetches` were a tools/call for the named tool. */
function toolCalls(fetches: string[], tool: string): number {
return fetches.filter((m) => m === 'tools/call:' + tool).length
}
test('a worker role stops the poll from calling fleet_inbox', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"worker"}')
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
await clock.advance(3_000)
expect(toolCalls(fetches, 'fleet_whoami')).toBe(1)
expect(toolCalls(fetches, 'fleet_inbox')).toBe(0)
expect(submitted.length).toBe(0)
})
test('an architect role stops the poll from calling fleet_inbox', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"architect"}')
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
await clock.advance(3_000)
expect(toolCalls(fetches, 'fleet_whoami')).toBe(1)
expect(toolCalls(fetches, 'fleet_inbox')).toBe(0)
expect(submitted.length).toBe(0)
})
test('a primary role keeps the poll calling fleet_inbox', async ($, on) => {
// The positive control for the two tests above: the same stubs, a role neither
// gates, so a bug that silenced every role would still pass them.
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"primary"}')
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
expect(toolCalls(fetches, 'fleet_inbox')).toBe(1)
expect(submitted.length).toBe(1)
})
test('an observer role keeps the poll calling fleet_inbox', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"observer"}')
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
expect(toolCalls(fetches, 'fleet_inbox')).toBe(1)
expect(submitted.length).toBe(1)
})
test('a whoami that fails on the first tick is retried and the poll resumes', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
let whoamiFails = true
on('http.fetch', (_$: any, e: any) => {
const body = JSON.parse(e.init.body)
if (body.method === 'tools/call' && body.params.name === 'fleet_whoami' && whoamiFails) {
fetches.push(body.method + ':' + body.params.name)
return { value: { ok: false, status: 503, headers: {}, text: '' } }
}
if (body.method === 'initialize') {
fetches.push(body.method)
return { value: { ok: true, status: 200, headers: { 'mcp-session-id': 'sid-1' }, text: '{}' } }
}
if (body.method === 'tools/call') {
fetches.push(body.method + ':' + body.params.name)
const text = body.params.name === 'fleet_whoami' ? '{"role":"observer"}' : JSON.stringify(inbox)
const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text }] } }
return { value: { ok: true, status: 200, headers: {}, text: 'data: ' + JSON.stringify(result) + '\n' } }
}
fetches.push(body.method)
return { value: { ok: true, status: 202, headers: {}, text: '' } }
})
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
expect(toolCalls(fetches, 'fleet_inbox')).toBe(0) // control: a failed whoami calls no fleet_inbox
expect(submitted.length).toBe(0)
whoamiFails = false
await clock.advance(3_000)
expect(toolCalls(fetches, 'fleet_inbox')).toBe(1)
expect(submitted.length).toBe(1)
})
test('whoami is called once, not on every tick, once the role is known', async ($, on) => {
const clock = mock.clock(on)
const submitted: string[] = []
stubSessionStart(on, submitted)
const fetches: string[] = []
const inbox = { sessionId: 'term_self', count: 0, messages: [] as string[] }
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"primary"}')
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
await clock.advance(3_000)
await clock.advance(3_000)
await clock.advance(3_000)
expect(toolCalls(fetches, 'fleet_whoami')).toBe(1)
expect(toolCalls(fetches, 'fleet_inbox')).toBe(3)
})
+3 -2
View File
@@ -1507,6 +1507,7 @@ else
grep -E ' (ERROR|SEVERE) ' "$FRESH_LOG" | tail -5 | sed 's/^/ /'
fi
echo
echo " Next: call fleet_whoami and confirm it still answers 'primary'. A lead whose tab label"
echo " no longer matches fleet.leaders.*.tab is demoted to worker and refuses orchestration."
echo " Next: call fleet_whoami and confirm it still answers 'primary'. A lead is found by its"
echo " tab being labelled 'lead' AND sitting in fleet.leaders.<name>.workspace; if either stops"
echo " matching, the lead is demoted to worker and refuses orchestration."
echo