Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3eeace447 | |||
| b696c31756 | |||
| 96ebae28a6 | |||
| b41aa663f7 | |||
| 027d413ce9 | |||
| f544906621 | |||
| c88f01ecb8 | |||
| 66ab9cab24 | |||
| f611488c9b | |||
| 44d16e7639 | |||
| 87f7c03535 | |||
| 562354a58d | |||
| eaa1f0d170 | |||
| 72ea7def0a | |||
| 98f3cd5aa7 | |||
| aec5ff2c2f | |||
| d9fedfbc5a | |||
| ac535503ff | |||
| 654b3b5e14 | |||
| 1fae8b81a0 | |||
| 4ef815682b | |||
| 6596458ce6 | |||
| 1cc0322782 | |||
| c043d149cf | |||
| fc786d0f67 | |||
| b314cb4d51 | |||
| a3d296f639 | |||
| 79423787d0 | |||
| 364b229db9 | |||
| ac436efefb | |||
| 7dad054045 | |||
| 4e414c475f | |||
| 9084667493 | |||
| 2289e94223 | |||
| 92adfcfae5 | |||
| 5f7f388e69 | |||
| 39accf73e6 | |||
| 5c2f296bc3 | |||
| 0f2ec7a6b5 | |||
| 02eff4c532 | |||
| 92b6d9406b | |||
| c4498607e1 | |||
| e42eab5b4c | |||
| b1d2cb48ac | |||
| 001367d82c | |||
| 9fdcaaa8fd | |||
| 459a523e2c | |||
| 291dc02c77 | |||
| be835aa259 | |||
| 80506c79d0 | |||
| 6677ec8c63 | |||
| ca0c965932 | |||
| e8ab933cbc | |||
| 2adb950a12 | |||
| 7f0c4a8464 | |||
| 682991a846 | |||
| abe617c48c | |||
| 886ce1521d | |||
| d41aff4012 | |||
| 8a1d73b39e | |||
| 70a735b638 | |||
| 803c91ea6c | |||
| d438a74575 | |||
| 7467ffa252 | |||
| f4176ae455 | |||
| 6ab3a81af7 | |||
| 8fa8d18c97 | |||
| 9d653e86df | |||
| f6d1131d7a | |||
| a0505dc614 | |||
| d2f30f1654 | |||
| cd1f04cbb4 | |||
| 73137f198f | |||
| 4ffe49f3bb | |||
| efd9cdb983 | |||
| aabecce901 | |||
| 6754b4edbc | |||
| 787ae0ed7a | |||
| e3050efe8b | |||
| 11998cd626 | |||
| 8e5394f63f | |||
| 428a12af62 | |||
| a332dfdb2c | |||
| d0f4ae057b | |||
| 7b3beaa209 | |||
| b3b2bf3da6 | |||
| 8bb2aa0be4 | |||
| 4ce3149bfd | |||
| 1fc9e85bf1 | |||
| 38544d467c | |||
| 809b7d9b20 | |||
| 8cf7215d56 | |||
| 1ef93e57cc | |||
| cf0c9b9316 | |||
| 337dbd491e | |||
| 7cf6075b79 | |||
| fbdcd709c9 | |||
| 8d3f10d291 | |||
| 38f4fd64ee | |||
| 3fc39b981d | |||
| 70328ca0f8 | |||
| efab9b8c49 | |||
| 2374de28e4 | |||
| dd18bd1f38 | |||
| efeffb4ab7 | |||
| ed4f4b08ad | |||
| 28a1f3d6f5 | |||
| b12d70716b | |||
| 0f5985b419 | |||
| 6e06058b07 | |||
| e5f4fb81ab | |||
| d28ab0968b | |||
| 9a64d42599 | |||
| f4e0ca41e6 | |||
| 29a2f97c25 | |||
| d2db8c7dc9 | |||
| 95311c6e8e | |||
| 10ab58e4fc | |||
| 0ba597e394 | |||
| c468953963 | |||
| 25d53e6ef7 | |||
| a9a37af957 | |||
| 7d497aa423 | |||
| b205bcc2aa | |||
| 11eccc3a1b | |||
| 4939d40362 | |||
| e7a7711a4e | |||
| 1fd2cfa716 | |||
| 3e8e314656 | |||
| 20fc42b572 | |||
| f82073717a | |||
| 16b52fac6c | |||
| 13b6ae2628 | |||
| 7e838ba8b9 | |||
| c3e3554bde | |||
| b5bc5d4ab5 | |||
| d83972ede2 | |||
| 02c6909546 | |||
| b92a669ddc | |||
| bb29b001e4 | |||
| f0ff25221e | |||
| 780cb342ad | |||
| 5c563f02c8 | |||
| ad593c9bb9 | |||
| 736fd9cf4b | |||
| 05244a82b3 | |||
| ef4996a01e | |||
| edbd8d816a | |||
| 9425a9b696 | |||
| d0688c8a60 | |||
| 2c467c2553 | |||
| c6430d8edd | |||
| bfee23acc3 | |||
| 7dec74f1b4 | |||
| 482598e2a6 | |||
| 5f5d16fbd4 | |||
| 7df7985a16 | |||
| 724b35b46e | |||
| 2eb2d6112e | |||
| 7c458e8bf2 | |||
| 133f03e428 | |||
| 804279175d | |||
| 9dea289975 | |||
| cb4a6869b9 | |||
| 28b45d97e5 | |||
| 1a397e962e | |||
| 209e1231ea | |||
| 5d8b9d365c | |||
| a6aeda39e7 | |||
| 31b3c24caa | |||
| d105da978d | |||
| 5051a06443 | |||
| a134eccc57 | |||
| 6f275227d2 | |||
| 283ccf8423 | |||
| b96fba4a03 | |||
| 6d97d210b4 | |||
| 37b23cd704 |
@@ -8,8 +8,8 @@
|
|||||||
{
|
{
|
||||||
"name": "fleet",
|
"name": "fleet",
|
||||||
"source": "./plugin",
|
"source": "./plugin",
|
||||||
"description": "Mount the fleetd MCP gateway and apply standard Claude Code settings so a session can orchestrate delegated workers. Ships no credentials.",
|
"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.2.0",
|
"version": "0.3.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "LTMS"
|
"name": "LTMS"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ as `matches HEAD`, `drift`, or `unknown`; do not turn an unclear timestamp into
|
|||||||
Report the process identifier (PID) and uptime too:
|
Report the process identifier (PID) and uptime too:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PIDS="$(pgrep -f 'target/fleetd.jar' || true)"
|
PIDS="$(pgrep -f 'fleetd.jar' || true)"
|
||||||
if [ -z "$PIDS" ]; then
|
if [ -z "$PIDS" ]; then
|
||||||
printf '%s\n' 'fleetd: not running'
|
printf '%s\n' 'fleetd: not running'
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ writes a handover file, and the new session reads that file and carries on.
|
|||||||
- **By hand.** You write the file, then tell the operator where it is. The operator starts the new
|
- **By hand.** You write the file, then tell the operator where it is. The operator starts the new
|
||||||
session and points it at the file. This always works.
|
session and points it at the file. This always works.
|
||||||
- **With `fleet_handover`** (fleetd #480, merged 2026-09-11). You ask fleetd to do the swap: it
|
- **With `fleet_handover`** (fleetd #480, merged 2026-09-11). You ask fleetd to do the swap: it
|
||||||
checks the file, clears your pane, and tells the fresh session to read it. This needs
|
checks the file, restarts your pane, and tells the fresh session to read it. This needs
|
||||||
`leadRollover:` in `fleetd.yaml`; without it every action answers a clean refusal naming
|
`leadRollover:` in `fleetd.yaml`; without it every action answers a clean refusal naming
|
||||||
`NOT_CONFIGURED`, and you fall back to the manual path. Section 11 below is the procedure.
|
`NOT_CONFIGURED`, and you fall back to the manual path. Section 11 below is the procedure.
|
||||||
|
|
||||||
@@ -133,8 +133,17 @@ A handover file is a record of state and decisions. It is not a diary.
|
|||||||
**Run the three steps in this order. The order is not a style choice — the wrong order is
|
**Run the three steps in this order. The order is not a style choice — the wrong order is
|
||||||
refused.**
|
refused.**
|
||||||
|
|
||||||
1. **`fleet_handover{action: "open", reason: "<why now>"}`.** It returns a `token` and the
|
1. **`fleet_handover{action: "open", reason: "<why now>"}`.** It returns a `token`, the
|
||||||
`handoverPath` you must write to. Nothing has happened to your pane yet.
|
`handoverPath` you must write to, and `outstandingTickets` plus `openAsks`. Nothing has happened
|
||||||
|
to your pane yet.
|
||||||
|
|
||||||
|
**Copy `outstandingTickets` and `openAsks` into the handover file.** Your successor keeps the
|
||||||
|
authority to poll those tickets and answer those asks, because both are gated on the lead's
|
||||||
|
name, which does not change when your pane does. What it does not keep is the ids — they exist
|
||||||
|
only in your context and in this response. A ticket already in a terminal phase is the urgent
|
||||||
|
one: its reply lives only in memory and is deleted once the ticket TTL passes, so an uncollected
|
||||||
|
report is lost for good. Poll those before you confirm, or name them in the file so your
|
||||||
|
successor polls them first.
|
||||||
|
|
||||||
**Write to exactly that path, and do not resolve it yourself.** It is always absolute, even when
|
**Write to exactly that path, and do not resolve it yourself.** It is always absolute, even when
|
||||||
the operator configured a relative `handoverPath`: fleetd resolves a relative one against your
|
the operator configured a relative `handoverPath`: fleetd resolves a relative one against your
|
||||||
@@ -159,11 +168,62 @@ fails.
|
|||||||
|
|
||||||
`{action: "cancel", token}` drops a pending request without rolling.
|
`{action: "cancel", token}` drops a pending request without rolling.
|
||||||
|
|
||||||
**Things that will surprise you:**
|
## 12. A roll restarts your process
|
||||||
|
|
||||||
- **`accepted` does not mean your pane has been cleared.** It means every gate passed and the roll
|
The daemon ends your pane, launches a fresh one, waits for the new terminal to be recognised as a
|
||||||
|
lead, and only then sends the bootstrap text. Your `claude` process really exits, so a newer CLI on
|
||||||
|
disk is loaded. It does not type `/clear`.
|
||||||
|
|
||||||
|
**The restart path is live in the code but has not run yet.** Measured 2026-10-05: the log holds no
|
||||||
|
`lead-rollover:` line since the current daemon started, and no occurrence of any new outcome name.
|
||||||
|
All 20 rolls recorded further down ran under the older `/clear` behaviour, so read them as history
|
||||||
|
rather than as evidence about your own roll. Re-measure with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c "lead-rollover: rolled" fleetd/fleetd.out # successful rolls
|
||||||
|
grep -c "lead-rollover:" fleetd/fleetd.out # positive control: must be larger
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the control line too. A broken pattern returns a clean `0` that reads exactly like good news.
|
||||||
|
If the first number has grown past 20, somebody has rolled under the restart path, and this section
|
||||||
|
should be replaced with what they measured.
|
||||||
|
|
||||||
|
**Three separate timeouts bound a roll.** `leadRollover.relaunchReadySeconds` (default 45,
|
||||||
|
`FleetConfig.java:1483`) bounds **each** of three waits that run after the relaunch, so the worst
|
||||||
|
case there is about three times that number, not 45 seconds in total. The third wait retries
|
||||||
|
`bootstrapText` while herdr answers `agent_not_ready`. A separate bound gives your old pane 10
|
||||||
|
seconds to die (`LeadRollover.PANE_DEATH_TIMEOUT_SECONDS`).
|
||||||
|
|
||||||
|
`fleet_handover{action: "status", token}` answers with one of these:
|
||||||
|
|
||||||
|
| Outcome | What it means |
|
||||||
|
|---|---|
|
||||||
|
| `IN_PROGRESS` | still running; it always ends on one of the rows below |
|
||||||
|
| `ROLLED` | the roll succeeded |
|
||||||
|
| `TURN_NEVER_SETTLED` | your turn ran past `leadRollover.turnSettleSeconds`; nothing was touched |
|
||||||
|
| `OLD_PANE_NEVER_DIED` | your pane did not exit within the 10-second bound |
|
||||||
|
| `RELAUNCH_FAILED` | launching the fresh pane failed |
|
||||||
|
| `RELAUNCH_NEVER_READY` | the fresh pane never became ready within `relaunchReadySeconds` |
|
||||||
|
| `RELAUNCH_NOT_RECOGNISED` | the fresh terminal never resolved as a lead |
|
||||||
|
| `BOOTSTRAP_NEVER_SENT` | the fresh pane was ready, but herdr refused `bootstrapText` with `agent_not_ready` for the whole bound, so the successor never learned where the handover file is |
|
||||||
|
| `FAILED` | the roll threw; `runRollover`'s catch records this rather than leaving it stuck |
|
||||||
|
|
||||||
|
Only `TURN_NEVER_SETTLED` guarantees your context is intact. The other failures can leave you
|
||||||
|
already gone, so you may never read them yourself — they are in the daemon log for whoever looks
|
||||||
|
next.
|
||||||
|
|
||||||
|
## 13. Things that will surprise you
|
||||||
|
|
||||||
|
- **`accepted` does not mean your pane has been restarted.** It means every gate passed and the roll
|
||||||
is scheduled to run once your current turn ends. Say your goodbye in the same turn — you will not
|
is scheduled to run once your current turn ends. Say your goodbye in the same turn — you will not
|
||||||
get another one.
|
get another one.
|
||||||
|
- **If you are still running after that turn, the roll did not happen.** A roll that works ends your
|
||||||
|
process, so surviving your own goodbye is itself the signal that it refused. Check with
|
||||||
|
`fleet_handover{action: "status", token}`, using the token you confirmed. `TURN_NEVER_SETTLED`
|
||||||
|
means your turn ran past `leadRollover.turnSettleSeconds` and **your pane was never ended**: your
|
||||||
|
context is intact and nothing was lost. Open a fresh request and retry. Never assume the roll
|
||||||
|
succeeded because `confirm` answered `accepted` — by the time it refuses, there is no caller left
|
||||||
|
to tell, so this check is the only thing that closes that gap.
|
||||||
- **There is no terminal or session parameter, on purpose.** The pane is always your own, resolved
|
- **There is no terminal or session parameter, on purpose.** The pane is always your own, resolved
|
||||||
from your connection, so you can only ever roll yourself.
|
from your connection, so you can only ever roll yourself.
|
||||||
- **`operatorConfirmed` is your report of what a human told you.** Do not pass `true` because you
|
- **`operatorConfirmed` is your report of what a human told you.** Do not pass `true` because you
|
||||||
@@ -186,41 +246,29 @@ fails.
|
|||||||
**deferred, not hot** — it is read once at boot, so an edit does nothing until the daemon is
|
**deferred, not hot** — it is read once at boot, so an edit does nothing until the daemon is
|
||||||
redeployed.
|
redeployed.
|
||||||
|
|
||||||
**Until fleetd #621 merges, the nudge text will tell you to ask the operator even where the
|
The context nudge tracks this value, so its text and the config agree (fleetd #621 —
|
||||||
daemon no longer requires it.** `LeadHeartbeatLoop.contextNotice()` hardcodes "ask the operator"
|
`LeadHeartbeatLoop.contextNotice` takes `requireOperatorConfirm`). You still read the config
|
||||||
and takes no config, so it cannot know. Trust the config value over the nudge text. Once #621 is
|
rather than the nudge, because the nudge only reaches you when your context is already high.
|
||||||
merged and deployed, the nudge matches the config and this warning can be deleted.
|
|
||||||
|
|
||||||
- **The roll can still refuse after `confirm` returns**, and by then there is no caller to tell.
|
- **The roll can still refuse after `confirm` returns**, and by then there is no caller to tell.
|
||||||
Those outcomes are logged only, as `lead-rollover:` lines in the daemon log.
|
Those outcomes are logged only, as `lead-rollover:` lines in the daemon log.
|
||||||
- **The bootstrap prompt works end to end. Measured 2026-09-22.** This used to say the fix was
|
- **The bootstrap prompt works end to end — measured under the older `/clear` path.** On 2026-09-22
|
||||||
unproven (fleetd #489) and told you to expect a failure. That is no longer true. The daemon log
|
the daemon log held four `lead-rollover: rolled` lines; on 2026-10-04 it held **20**, against a
|
||||||
now holds four `lead-rollover: rolled` lines, and three of them ran on 2026-09-22 at 10:01:43,
|
control of 86 `lead-rollover:` lines. Each roll started a fresh session against the handover file,
|
||||||
10:38:28 and 11:15:47. Each one cleared the old lead and started a fresh session against the
|
with the configured `bootstrapText` arriving as its first message, and no context was lost. So the
|
||||||
handover file, with the configured `bootstrapText` arriving as its first message. No context was
|
bootstrap half of the roll is proven, and that half did not change. Section 12 says how to check
|
||||||
lost. The old `Unknown command: /clearFresh` failure from 2026-09-12 does not appear in the log
|
whether anything has rolled under the restart path since.
|
||||||
at all. Re-measure both numbers with:
|
|
||||||
|
|
||||||
```bash
|
19 of the 20 carry an `elapsedMs`: median 16507 ms, maximum 48261 ms, and two above 45000 ms. That
|
||||||
grep -c "lead-rollover: rolled" fleetd/fleetd.out # successful rolls
|
figure times the **whole** roll, and the wait for your own turn to end dominates it. Expect a roll
|
||||||
grep -c "lead-rollover:" fleetd/fleetd.out # positive control: must be larger
|
to take tens of seconds, and do not treat a slow one as a failed one. The restart path adds a pane
|
||||||
grep -c "Unknown command" fleetd/fleetd.out # the old failure: expect 0
|
death and a relaunch to that work, so expect it to be slower rather than faster — but nobody has
|
||||||
```
|
measured it, so do not quote a number for it.
|
||||||
|
|
||||||
Run the control line too. A broken pattern returns a clean `0` that reads exactly like good news.
|
|
||||||
If the first number stops growing across rolls, or `Unknown command` returns anything above 0,
|
|
||||||
the bootstrap has regressed and this paragraph is stale again.
|
|
||||||
|
|
||||||
**You still write the file before you confirm, and never the other way round.** That order is not
|
**You still write the file before you confirm, and never the other way round.** That order is not
|
||||||
about the bootstrap being unreliable. It is what the daemon checks: the handover file must have
|
about the bootstrap being unreliable. It is what the daemon checks: the handover file must have
|
||||||
been modified *after* the open request, or `confirm` refuses it as stale.
|
been modified *after* the open request, or `confirm` refuses it as stale.
|
||||||
|
|
||||||
- **One warning in the log is normal and is not a failure.** Every one of the three rolls above also
|
|
||||||
logged `/clear on term_… was never observed as WORKING after 8 consecutive IDLE/DONE polls —
|
|
||||||
releasing rather than wedging the roll`. The daemon could not see the pane go WORKING after
|
|
||||||
`/clear`, so it released instead of hanging. The roll then succeeded anyway. That is the safe
|
|
||||||
branch behaving correctly. Do not report it as a broken roll.
|
|
||||||
|
|
||||||
## Writing style
|
## Writing style
|
||||||
|
|
||||||
Write in plain English. Use everyday words, one idea per sentence, and active voice. Keep every
|
Write in plain English. Use everyday words, one idea per sentence, and active voice. Keep every
|
||||||
|
|||||||
@@ -22,19 +22,22 @@ scripts/redeploy-fleetd.sh --yes # skip the drain prompt (fleet already chec
|
|||||||
scripts/redeploy-fleetd.sh --no-build # restart the jar already on disk
|
scripts/redeploy-fleetd.sh --no-build # restart the jar already on disk
|
||||||
```
|
```
|
||||||
|
|
||||||
`--no-build` skips the build and restarts whatever jar is at `fleetd/target/fleetd.jar`. Use it only
|
`--no-build` skips the build and restarts whatever jar is at `fleetd/run/fleetd.jar` — the runtime
|
||||||
when you just built and nothing changed since. It gives up the protection in the next paragraph: no
|
path, not Maven's output path. Use it only when you just built and nothing changed since. It gives
|
||||||
build runs, so a stale or missing jar is not caught early. The script still checks the file is there
|
up the protection in the next paragraph: no build runs, so a stale or missing jar is not caught
|
||||||
and dies with `no jar at … — run without --no-build` if it is not, but it cannot tell you the jar is
|
early. The script still checks the file is there and dies with `no jar at … — run without
|
||||||
old. Any build that writes `fleetd/target/fleetd.jar` while the daemon runs, including `mvn install`
|
--no-build` if it is not, but it cannot tell you the jar is old.
|
||||||
with or without `clean`, breaks that daemon's shutdown drain. The drain loads its classes lazily at
|
|
||||||
shutdown from the jar file the JVM opened at boot. Deleting is not the only hazard; replacing the jar
|
The daemon runs from `fleetd/run/fleetd.jar`, not from `fleetd/target/fleetd.jar` where Maven
|
||||||
is enough. Nothing warns at the time. The damage appears at the next restart, where it looks like the
|
writes its output (fleetd #664). That split is what makes a bare `mvn install`/`mvn clean` in the
|
||||||
restart's fault. Verify a merge by building in a throwaway git worktree. Let only
|
main clone harmless now: neither can reach the file the running daemon holds open, because that
|
||||||
`scripts/redeploy-fleetd.sh` touch the main clone's jar. Its stage-then-swap protects its own build,
|
file no longer lives under `target/` at all. Verify a merge by building in a throwaway git
|
||||||
but it cannot undo a replacement that already happened. Run `--check` first: it prints the jar's hash
|
worktree anyway — a build still produces nothing the fleet runs until this script's own `mv` of
|
||||||
and its modification time, so you can see for yourself whether the jar is missing or older than the
|
`target/fleetd.jar` onto `run/fleetd.jar`, performed only after the old daemon is confirmed gone.
|
||||||
code you mean to ship.
|
Let only `scripts/redeploy-fleetd.sh` touch `fleetd/run/fleetd.jar`. Run `--check` first: it prints
|
||||||
|
the BUILT jar (`target/fleetd.jar`) and the RUNNING jar (`run/fleetd.jar`) as two separately
|
||||||
|
labelled hash-and-mtime facts, so a mismatch between them — a build sitting unswapped, or a stale
|
||||||
|
runtime jar — is visible before you decide anything.
|
||||||
|
|
||||||
It builds before it stops anything, so a failed build never leaves the fleet down; it waits for the
|
It builds before it stops anything, so a failed build never leaves the fleet down; it waits for the
|
||||||
old process to exit rather than assuming; it polls `/healthz`; and it anchors its log checks to a
|
old process to exit rather than assuming; it polls `/healthz`; and it anchors its log checks to a
|
||||||
@@ -56,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
|
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
|
it. The startup log names which keys it accepted and which it deferred — read those lines rather
|
||||||
than assuming.
|
than assuming.
|
||||||
4. **Re-check identity afterwards.** Call `fleet_whoami` and confirm it still answers `primary`. The
|
4. **Re-check identity afterwards.** Call `fleet_whoami` and confirm it still answers `primary`. A
|
||||||
lead is found by its tab label (`fleet.leaders.*.tab`), and a lead whose tab no longer matches is
|
lead is found by two things together: its tab is labelled `lead`, and that tab sits in the space
|
||||||
demoted to worker, which refuses every orchestration call.
|
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
|
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
|
`fleetd/fleetd.out`, dated after the restart. An old daemon that never died looks identical from
|
||||||
the outside.
|
the outside.
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ confident-but-wrong finding. Anything you could settle by reading more code is y
|
|||||||
|
|
||||||
## 4. The finding — what goes in `fleet_reply`
|
## 4. The finding — what goes in `fleet_reply`
|
||||||
|
|
||||||
|
**Call `fleet_reply` as soon as you know your answer, before you write the reasoning out.** The
|
||||||
|
four lines below are the whole deliverable, and they are short on purpose. Analysis you type into
|
||||||
|
your terminal reaches nobody: when a turn ends with no `fleet_reply`, the bridge scrapes the pane
|
||||||
|
and the lead receives a clipped fragment instead of a finding. A long, correct analysis and no
|
||||||
|
`fleet_reply` is a failed turn, and it is the most common way this role fails.
|
||||||
|
|
||||||
|
If the delegation also handed you a list of things to check, that list is where to *look*. It is
|
||||||
|
not the shape of the answer. Work the list, then still send these four lines.
|
||||||
|
|
||||||
Report the **single most important** real issue in the scope, in these four lines, under
|
Report the **single most important** real issue in the scope, in these four lines, under
|
||||||
~90 words:
|
~90 words:
|
||||||
|
|
||||||
|
|||||||
@@ -27,23 +27,38 @@ through its `fleet_*` tools. No session addresses a peer, a broker, or the netwo
|
|||||||
**Every role reads this file.** A member runs in a git worktree of this same repo, so it inherits
|
**Every role reads this file.** A member runs in a git worktree of this same repo, so it inherits
|
||||||
this `CLAUDE.md` verbatim, and every rule below is role-conditional.
|
this `CLAUDE.md` verbatim, and every rule below is role-conditional.
|
||||||
|
|
||||||
**Call `fleet_whoami`.** It returns `primary`, `worker`, or `architect`, resolved by the daemon from
|
**Call `fleet_whoami`.** It returns `primary`, `worker`, `architect`, `collaborator`, or `observer`,
|
||||||
your connection — unforgeable, and the same resolution its authorization gate uses. A worker also
|
resolved by the daemon from your connection — unforgeable, and the same resolution its authorization
|
||||||
carries its `sessionId`, `profile`, `worktree` and `branch`; an architect carries the slot name it
|
gate uses. A worker also carries its `sessionId`, `profile`, `worktree` and `branch`; an architect
|
||||||
was bound to. Don't infer what you can ask.
|
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`, 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
|
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
|
fires: the reply charter in your system prompt (*"You are a spawned member in the
|
||||||
claude-bridge fleet"*) ⇒ **spawned member**; fleet tools prefixed `mcp__fleet__*` ⇒ **spawned
|
claude-bridge fleet"*) ⇒ **spawned member**; fleet tools prefixed `mcp__fleet__*` ⇒ **spawned
|
||||||
member** (the launcher fixes that mount name; a primary's mount is named by whoever wrote its
|
member** (the launcher fixes that mount name; a primary's mount is named by whoever wrote its
|
||||||
`.mcp.json`, so it varies — and a member spawned before CB-632 still says `mcp__bridge__*`); `ANTHROPIC_BASE_URL` set ⇒ **spawned member** (Claude-model members run
|
`.mcp.json`, so it varies — and a member spawned before CB-632 still says `mcp__bridge__*`); `ANTHROPIC_BASE_URL` set ⇒ **spawned member** (Claude-model members run
|
||||||
on a clean env, so its *absence* proves nothing). None of these separate a worker from an architect —
|
on a clean env, so its *absence* proves nothing). None of these separate a worker from an architect,
|
||||||
only `fleet_whoami` does. **Still unsure ⇒ act as a worker**, the most restricted member role. The
|
or a worker from an **observer** — an observer is just as unspawned as a collaborator and carries
|
||||||
two mistakes are not symmetric: a primary acting as a worker is refused by the authorization gate —
|
none of these signals either, so only `fleet_whoami` tells the two apart. **And none of them fires
|
||||||
loud and self-correcting — while a member acting as the primary ends its turn with no `fleet_reply`,
|
for a collaborator at all**: every signal in the ladder detects a *spawned* member, while a
|
||||||
|
collaborator is a tab a person opened by hand, so it has no charter, no fixed mount name and a
|
||||||
|
normal environment. A collaborator — or an observer — that cannot call `fleet_whoami` therefore falls
|
||||||
|
to the line below and acts as a worker. That is the safe direction — it under-privileges, and the
|
||||||
|
refusals are loud — but it means a collaborator or an observer has no way to learn what it is except
|
||||||
|
by asking. **Still unsure ⇒ act as a worker**, the most restricted member role this ladder can name.
|
||||||
|
The two mistakes are not symmetric: a primary acting as a worker is refused by the authorization gate
|
||||||
|
— loud and self-correcting — while a member acting as the primary ends its turn with no `fleet_reply`,
|
||||||
and the sender silently receives nothing. Fail toward the recoverable error.
|
and the sender silently receives nothing. Fail toward the recoverable error.
|
||||||
|
|
||||||
### Invariants — both roles, no exceptions
|
### Invariants — every role, no exceptions
|
||||||
|
|
||||||
1. **Never set, export, or forward `ANTHROPIC_BASE_URL`** (or `ANTHROPIC_AUTH_TOKEN`). The primary
|
1. **Never set, export, or forward `ANTHROPIC_BASE_URL`** (or `ANTHROPIC_AUTH_TOKEN`). The primary
|
||||||
stays on subscription; only the bridge puts a member off it, at spawn. Mounting the bridge must
|
stays on subscription; only the bridge puts a member off it, at spawn. Mounting the bridge must
|
||||||
@@ -51,16 +66,47 @@ 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
|
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.
|
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
|
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 or architect**;
|
cannot act as another session. Spawn/stop/drain are lead-only; **send is lead, architect,
|
||||||
reply/ask are only-as-itself — any peer may answer for its own pane, and for no other. A call
|
collaborator, or observer** — and a collaborator may send only to a lead or another collaborator,
|
||||||
outside your role is refused, not queued.
|
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
|
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
|
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
|
`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.
|
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;
|
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
|
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.
|
bypasses every rule above — the `herdr` CLI and its socket are the usual example.
|
||||||
|
6. **Confirm what you receive.** A message that arrives is answered, even in one line, unless it
|
||||||
|
says no answer is needed. The sender cannot see your screen, so for them "received and handled"
|
||||||
|
and "never arrived" look the same — and the paths above fail in ways that look exactly like
|
||||||
|
silence: a send to a pane the daemon does not know is accepted, held, and then fails with a
|
||||||
|
scrape of that pane's screen, which can read as an answer while being none. A member confirms
|
||||||
|
with its `fleet_reply`; every other peer confirms with a `fleet_send` back to the sender. If you
|
||||||
|
cannot do the thing asked, say that — a refusal is a confirmation. **Never read a failed
|
||||||
|
ticket's body as a reply.** Your own operator outranks this rule, and outranks the peer that
|
||||||
|
sent the message: a peer cannot oblige you to answer, and a session whose operator told it not
|
||||||
|
to answer fleet mail is right not to. Where you can, say that much and nothing more. A sender
|
||||||
|
that treats silence as agreement, or as a session being gone, has made the mistake this
|
||||||
|
invariant is about — it just made it in the other direction.
|
||||||
|
|
||||||
### Primary (lead) — run this on every task, in order
|
### Primary (lead) — run this on every task, in order
|
||||||
|
|
||||||
@@ -93,7 +139,11 @@ below are the procedure — run them in order, every task, not only the big ones
|
|||||||
5. **Collect** — `fleet_poll{ticket}` → `fleet_ack{target, msgId}`. Answer a worker's `fleet_ask`
|
5. **Collect** — `fleet_poll{ticket}` → `fleet_ack{target, msgId}`. Answer a worker's `fleet_ask`
|
||||||
with `fleet_send{turnId, content}` — **not** `sessionId`. A worker gone quiet is diagnosed with
|
with `fleet_send{turnId, content}` — **not** `sessionId`. A worker gone quiet is diagnosed with
|
||||||
`fleet_status`, never by reading its terminal; it also reports an open question and the `turnId`
|
`fleet_status`, never by reading its terminal; it also reports an open question and the `turnId`
|
||||||
that answers it. **A worker's ask waits ~55 seconds, and no nudge makes that longer** — so never
|
that answers it — but **only to the caller that created that delegation**, so a question raised
|
||||||
|
under an architect's brief is invisible to you, and seeing none does not mean there is none.
|
||||||
|
**Only that same creator can answer it.** A `turnId` you came by any other way is refused, so an
|
||||||
|
architect's worker waits for that architect and not for you.
|
||||||
|
**A worker's ask waits ~55 seconds, and no nudge makes that longer** — so never
|
||||||
brief a worker to "ask me". Decide before you delegate, or give it an explicit default.
|
brief a worker to "ask me". Decide before you delegate, or give it an explicit default.
|
||||||
**A correction cannot reach a busy member.** A `fleet_send` to a working member is *accepted* and
|
**A correction cannot reach a busy member.** A `fleet_send` to a working member is *accepted* and
|
||||||
returns a ticket, and is then never delivered — measured here three times in one session, and the
|
returns a ticket, and is then never delivered — measured here three times in one session, and the
|
||||||
@@ -158,12 +208,15 @@ you decide.
|
|||||||
| See the fleet | `fleet_list` → `leads` (your peers) + `members` (each carries `agentSessionId` when its backend knows one) + `loopHealth` (`RUNNING`, `STALLED`, or `STOPPED` for `statusPoller` and `sessionReaper`) · one peer's state: `fleet_status{sessionId}` |
|
| See the fleet | `fleet_list` → `leads` (your peers) + `members` (each carries `agentSessionId` when its backend knows one) + `loopHealth` (`RUNNING`, `STALLED`, or `STOPPED` for `statusPoller` and `sessionReaper`) · one peer's state: `fleet_status{sessionId}` |
|
||||||
| Delegate (blocking) | `fleet_send{sessionId, content}` |
|
| Delegate (blocking) | `fleet_send{sessionId, content}` |
|
||||||
| Delegate (long task) | `fleet_send{sessionId, content, wait:false}` → ticket → `fleet_poll{ticket}` |
|
| Delegate (long task) | `fleet_send{sessionId, content, wait:false}` → ticket → `fleet_poll{ticket}` |
|
||||||
| Answer a member's `fleet_ask` | `fleet_send{turnId, content}` — **not** `sessionId` |
|
| Answer a member's `fleet_ask` | `fleet_send{turnId, content}` — **not** `sessionId`, and only the caller that created that delegation |
|
||||||
| 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 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 **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 |
|
| 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 |
|
| 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 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}` |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
@@ -234,6 +287,27 @@ simply complies has thrown away the reason there are two of you.
|
|||||||
7. **Never merge.** Stage files explicitly — never `git add -A` — and leave alone anything the
|
7. **Never merge.** Stage files explicitly — never `git add -A` — and leave alone anything the
|
||||||
project marks as not-yours-to-commit.
|
project marks as not-yours-to-commit.
|
||||||
|
|
||||||
|
### Collaborator — a named peer, not a member
|
||||||
|
|
||||||
|
`fleet_whoami` answered `collaborator`, so your pane's tab matches a `fleet.collaborators.<name>.tab`
|
||||||
|
entry. You are **not** a member: nothing delegates to you, you have no brief, no worktree and no
|
||||||
|
ticket, and **you owe no `fleet_reply`** — the turn contract above is for a session a lead spawned,
|
||||||
|
and it does not apply to you. Read it only to understand what the members around you are doing.
|
||||||
|
|
||||||
|
What you may do: observe the fleet (`fleet_list`, `fleet_profiles`, `fleet_whoami`), and send to a
|
||||||
|
lead or to another collaborator. What you may not: spawn, stop or drain anything, roll a lead's
|
||||||
|
session, answer a member's `fleet_ask`, poll a ticket, or send to a spawned member's terminal. Each
|
||||||
|
of those is refused at the gate, not queued.
|
||||||
|
|
||||||
|
Two limits worth knowing before you hit them. **You cannot reach a worker** — not even to help one —
|
||||||
|
because a worker belongs to the lead that spawned it, and routing around that would make you a
|
||||||
|
second orchestrator with no plan. Send to the lead instead. And **you cannot read a ticket**, so you
|
||||||
|
cannot collect a delegation's reply: `fleet_poll` refuses you at the role gate, and a ticket also
|
||||||
|
records the terminal that created it, so even a leaked id reads nothing.
|
||||||
|
|
||||||
|
Being named buys you a channel, not authority. Your `fleet_send` to a lead is coordination between
|
||||||
|
peers: the lead owes you no obedience, and you owe it none.
|
||||||
|
|
||||||
### Where each rule lives (don't duplicate — extend the right layer)
|
### Where each rule lives (don't duplicate — extend the right layer)
|
||||||
|
|
||||||
| Layer | Scope | Reaches |
|
| Layer | Scope | Reaches |
|
||||||
@@ -295,16 +369,37 @@ must obey belongs in the charter, not here.
|
|||||||
`handover` (write the file a fresh lead session inherits when the outgoing one hands off,
|
`handover` (write the file a fresh lead session inherits when the outgoing one hands off,
|
||||||
fleetd #480).
|
fleetd #480).
|
||||||
- **This repo is also a Claude Code marketplace, and ships a plugin.** `.claude-plugin/marketplace.json`
|
- **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
|
points at `plugin/`, which carries the `setup` skill (`/fleet:setup` — make any project
|
||||||
(`/claude-bridge:setup` — make any project bridge-ready). It was added in CB-527 and then went
|
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
|
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
|
(#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
|
structural, not bugs: a plugin cannot carry the role agent files, because
|
||||||
`ClaudeCodeLauncher.java:371` requires `<cwd>/.claude/agents/<role>.md` in the member's own
|
`ClaudeCodeLauncher.java:371` requires `<cwd>/.claude/agents/<role>.md` in the member's own
|
||||||
worktree; and a plugin cannot deliver anything to members at all, because
|
worktree; and a plugin reaches a member only through `CLAUDE_CONFIG_DIR`, which
|
||||||
`ClaudeCodeLauncher.java:285` exports `CLAUDE_CONFIG_DIR` and every Claude profile here sets it,
|
`ClaudeCodeLauncher.java:286` exports with `putIfPresent` — so only for a profile that sets
|
||||||
so a member never reads the operator's plugin store. **The plugin is the lead-side surface;
|
`configDir`. Every `claude-code` profile does set one (the four without are `opencode`, which
|
||||||
member-facing assets travel in the worktree.**
|
never reads that variable). **But measured 2026-10-04: two of them point at
|
||||||
|
`~/.ccs/instances/ltms`, which is the operator's own `CLAUDE_CONFIG_DIR` on this host.** So for an
|
||||||
|
`opus` or `sonnet` member, "a member never reads the operator's plugin store" is false — it reads
|
||||||
|
the same store, because that store is the one its `configDir` names. It stays true for `local` and
|
||||||
|
`local-direct`, which point at `~/.ccs/instances/gx10`. `ClaudeCodeLauncher`'s own javadoc names
|
||||||
|
the related hazard: that file is rewritten on every spawn, so for those two profiles fleetd and the
|
||||||
|
operator's live session write the same `.claude.json`, and its compare-and-swap "narrows the
|
||||||
|
lost-update window, it does not close it". Re-measure which profiles share the operator's dir with
|
||||||
|
`awk '/^profiles:/{i=1;next} /^[a-z]/{i=0} i&&/^ [a-z-]+:$/{p=$1} i&&/configDir:/{print p,$2}'
|
||||||
|
fleetd/fleetd.yaml` against `echo $CLAUDE_CONFIG_DIR`; delete this note once no profile names the
|
||||||
|
operator's dir. **Treat the plugin as the lead-side surface and put member-facing assets in the
|
||||||
|
worktree** — that conclusion holds either way, because a worktree asset does not depend on which
|
||||||
|
config dir a member reads.
|
||||||
- **Never commit** `.mcp.json` (the primary's local copy, flagged `--skip-worktree`) or `wiki/`
|
- **Never commit** `.mcp.json` (the primary's local copy, flagged `--skip-worktree`) or `wiki/`
|
||||||
(a submodule with its own remote).
|
(a submodule with its own remote).
|
||||||
- **A provisioned worktree neutralizes `.mcp.json`, `opencode.json` and `.autoenv`** — the repo's
|
- **A provisioned worktree neutralizes `.mcp.json`, `opencode.json` and `.autoenv`** — the repo's
|
||||||
@@ -323,12 +418,13 @@ must obey belongs in the charter, not here.
|
|||||||
reference**, with the intent→tool table above as the short form. `McpContractDocTest` fails if
|
reference**, with the intent→tool table above as the short form. `McpContractDocTest` fails if
|
||||||
that page names a `fleet_*` tool the server does not register. The flows are kept out of this
|
that page names a `fleet_*` tool the server does not register. The flows are kept out of this
|
||||||
file because this file loads into every session's context.
|
file because this file loads into every session's context.
|
||||||
- **Never build into the main clone while `fleetd` runs.** Any build that writes
|
- **The daemon runs from `fleetd/run/fleetd.jar`, not `fleetd/target/fleetd.jar`** (fleetd #664).
|
||||||
`fleetd/target/fleetd.jar`, with or without `clean`, breaks the shutdown drain because its classes
|
Maven's own output still lands at `fleetd/target/fleetd.jar` — that part of the build is
|
||||||
load lazily from the jar file the JVM opened at boot. Nothing warns at the time. The damage appears
|
unchanged — but the running daemon never has that file open, so a bare `mvn install`/`mvn clean`
|
||||||
at the next restart, where it looks like the restart's fault. Verify merges in a throwaway git
|
in the main clone no longer corrupts anything a live process is reading. Verify merges in a
|
||||||
worktree. Let only `scripts/redeploy-fleetd.sh` touch the main clone's jar. Its stage-then-swap
|
throwaway git worktree anyway: a build in the main clone still ships nothing until
|
||||||
cannot undo a replacement that already happened.
|
`scripts/redeploy-fleetd.sh` moves it into place with its own atomic `mv`, performed only after
|
||||||
|
the old daemon is confirmed gone. Let only that script touch `fleetd/run/fleetd.jar`.
|
||||||
|
|
||||||
### Redeploying the daemon — the lead may do this (primary only)
|
### Redeploying the daemon — the lead may do this (primary only)
|
||||||
|
|
||||||
@@ -358,7 +454,7 @@ Before you call any work done, check the row that matches what you touched:
|
|||||||
| `ConnectionIdentity` / how a caller is resolved | the `fleet_whoami` paragraph and the fallback ladder |
|
| `ConnectionIdentity` / how a caller is resolved | the `fleet_whoami` paragraph and the fallback ladder |
|
||||||
| `REPLY_CHARTER`, or a launcher's mount/flags | the fallback ladder (`mcp__fleet__*`), and the layering table's top row |
|
| `REPLY_CHARTER`, or a launcher's mount/flags | the fallback ladder (`mcp__fleet__*`), and the layering table's top row |
|
||||||
| the injector / status gating | invariant 4 |
|
| the injector / status gating | invariant 4 |
|
||||||
| worktree provisioning or the parity overlay | the "both roles read this file" premise — it rests on the worker's worktree being a checkout of this repo |
|
| worktree provisioning or the parity overlay | the "every role reads this file" premise — it rests on the worker's worktree being a checkout of this repo |
|
||||||
| `.claude/skills/**` | the addendum's skill list, and the "name the playbook" rule |
|
| `.claude/skills/**` | the addendum's skill list, and the "name the playbook" rule |
|
||||||
| a new peer kind (non-Claude adapter) | what that peer can read — anything it must obey belongs in its charter, not in the block |
|
| a new peer kind (non-Claude adapter) | what that peer can read — anything it must obey belongs in its charter, not in the block |
|
||||||
| **anything an operator can use, configure, or observe** — an MCP tool, a `fleetd.yaml` knob, an endpoint, a visible behaviour | **[Features](wiki/11-Features.md)** — one entry: what it does · the knob that turns it on · **why it exists** · the gotcha |
|
| **anything an operator can use, configure, or observe** — an MCP tool, a `fleetd.yaml` knob, an endpoint, a visible behaviour | **[Features](wiki/11-Features.md)** — one entry: what it does · the knob that turns it on · **why it exists** · the gotcha |
|
||||||
@@ -451,3 +547,33 @@ to replace them.
|
|||||||
|
|
||||||
Prefer the unnamed lambda parameter `_` for required-but-unused params; a non-public
|
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`.
|
`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.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<string>/Users/dai.ha/LTMS/claude-bridge/scripts/fleetd-launchd-wrapper.sh</string>
|
<string>/Users/dai.ha/LTMS/claude-bridge/scripts/fleetd-launchd-wrapper.sh</string>
|
||||||
<string>/Users/dai.ha/Softwares/jdks/jdk-25.0.3.jdk/Contents/Home/bin/java</string>
|
<string>/Users/dai.ha/Softwares/jdks/jdk-25.0.3.jdk/Contents/Home/bin/java</string>
|
||||||
<string>-jar</string>
|
<string>-jar</string>
|
||||||
<string>/Users/dai.ha/LTMS/claude-bridge/fleetd/target/fleetd.jar</string>
|
<string>/Users/dai.ha/LTMS/claude-bridge/fleetd/run/fleetd.jar</string>
|
||||||
<string>fleetd.yaml</string>
|
<string>fleetd.yaml</string>
|
||||||
</array>
|
</array>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ WorkingDirectory=%h/LTMS/fleetd/fleetd
|
|||||||
# and looks healthy, and the failure appears hours later as a member that cannot open a pull
|
# and looks healthy, and the failure appears hours later as a member that cannot open a pull
|
||||||
# request. exec keeps it one process, so systemd tracks the right PID.
|
# request. exec keeps it one process, so systemd tracks the right PID.
|
||||||
# This also avoids a SECOND copy of the secrets in a systemd drop-in: one source of truth.
|
# This also avoids a SECOND copy of the secrets in a systemd drop-in: one source of truth.
|
||||||
ExecStart=/bin/zsh -lc "exec java -jar target/fleetd.jar fleetd.yaml"
|
ExecStart=/bin/zsh -lc "exec java -jar run/fleetd.jar fleetd.yaml"
|
||||||
|
|
||||||
# PrivateTmp MUST stay false -- see herdr.service. fleetd creates the member ZDOTDIR scrub dir and
|
# PrivateTmp MUST stay false -- see herdr.service. fleetd creates the member ZDOTDIR scrub dir and
|
||||||
# the opencode config dir under java.io.tmpdir, and the member pane (a herdr child, a different
|
# the opencode config dir under java.io.tmpdir, and the member pane (a herdr child, a different
|
||||||
|
|||||||
@@ -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.
|
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
|
- **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.
|
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
|
`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
|
not a pane that is safe to write to — see fleetd #176 for what happens when a gate treats an
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
target/
|
target/
|
||||||
dependency-reduced-pom.xml
|
dependency-reduced-pom.xml
|
||||||
|
|
||||||
|
# The daemon's runtime jar (fleetd #664). scripts/redeploy-fleetd.sh moves the built jar here
|
||||||
|
# with a same-filesystem rename; this is never Maven's output path and never belongs in git.
|
||||||
|
run/
|
||||||
|
|
||||||
# Local runtime config (copy from fleetd.example.yaml). Both names are ignored: fleetd.yaml is
|
# Local runtime config (copy from fleetd.example.yaml). Both names are ignored: fleetd.yaml is
|
||||||
# the current name, and bridged.yaml is the legacy name Fleetd still falls back to.
|
# the current name, and bridged.yaml is the legacy name Fleetd still falls back to.
|
||||||
fleetd.yaml
|
fleetd.yaml
|
||||||
|
|||||||
+48
-25
@@ -62,11 +62,12 @@ bind:
|
|||||||
#
|
#
|
||||||
# Leads are configured under `fleet.leaders:` — see THE FLEET further down.
|
# Leads are configured under `fleet.leaders:` — see THE FLEET further down.
|
||||||
#
|
#
|
||||||
# Two things stop the tab-name convention from becoming a way to claim leadership: the configured
|
# Two things stop the tab-name convention from becoming a way to claim leadership: startup REFUSES
|
||||||
# member spaces are excluded from the scan, so nothing fleetd places can land in a matching tab;
|
# a `tabPrefix` that the fleet tabLabel template, or any per-profile `tabLabel` override, also
|
||||||
# and startup REFUSES a `tabPrefix` that the fleet tabLabel template, or any per-profile `tabLabel`
|
# matches, so the two namespaces cannot overlap by accident; and the CallerResolver asks the live
|
||||||
# override, also matches — so the two namespaces cannot overlap by accident. The label is a NAME,
|
# spawned-member roster BEFORE any tab map, so a live member is never mistaken for a lead no matter
|
||||||
# never a capability: what a pane may do is decided by the role the daemon resolves for it.
|
# what its tab says. The label is a NAME, never a capability: what a pane may do is decided by the
|
||||||
|
# role the daemon resolves for it.
|
||||||
|
|
||||||
# CB-551: IDLE-LEAD HEARTBEAT — nudge the single lead back to work when it has been continuously
|
# CB-551: IDLE-LEAD HEARTBEAT — nudge the single lead back to work when it has been continuously
|
||||||
# idle (no open fleet_send driving it) past the quiet period. The fleet is one lead + architects +
|
# idle (no open fleet_send driving it) past the quiet period. The fleet is one lead + architects +
|
||||||
@@ -98,17 +99,17 @@ bind:
|
|||||||
# contextHighNudge: false
|
# contextHighNudge: false
|
||||||
|
|
||||||
# Lead rollover (fleetd #480): replace a lead session that has decided it is ready to be replaced,
|
# Lead rollover (fleetd #480): replace a lead session that has decided it is ready to be replaced,
|
||||||
# without an operator doing it by hand. A lead writes a handover file, then asks fleetd to clear its
|
# without an operator doing it by hand. A lead writes a handover file, then asks fleetd to end its
|
||||||
# own pane and bootstrap a fresh session against that file.
|
# own pane, launch a fresh one, and bootstrap that fresh session against the handover file.
|
||||||
#
|
#
|
||||||
# Opt-in on purpose — it clears the lead's own pane on request, so upgrading the daemon must never
|
# Opt-in on purpose — it tears down the lead's own pane on request, so upgrading the daemon must
|
||||||
# acquire that ability for you. Absent block = feature off, and nothing is constructed at all. Even
|
# never acquire that ability for you. Absent block = feature off, and nothing is constructed at all.
|
||||||
# once present, nothing but an explicit confirm() call — one that passes every check — can ever
|
# Even once present, nothing but an explicit confirm() call — one that passes every check — can ever
|
||||||
# cause a /clear: there is no recurring timer, heartbeat or scheduler anywhere in this feature that
|
# tear a pane down: there is no recurring timer, heartbeat or scheduler anywhere in this feature that
|
||||||
# fires one on its own initiative. confirm() itself is called FROM the calling lead's own turn, so
|
# fires one on its own initiative. confirm() itself is called FROM the calling lead's own turn, so it
|
||||||
# it cannot clear the pane inline (that pane is still WORKING); instead it schedules a one-shot
|
# cannot act on the pane inline (that pane is still WORKING); instead it schedules a one-shot
|
||||||
# continuation that waits for the SAME confirm() call's turn to end, then does the actual work. See
|
# continuation that waits for the SAME confirm() call's turn to end, then does the actual work. See
|
||||||
# dev.ltms.fleet.lead.LeadRollover's class javadoc for the exact order (fleetd #480 correction).
|
# dev.ltms.fleet.lead.LeadRollover's class javadoc for the exact order.
|
||||||
#
|
#
|
||||||
# handoverPath: REQUIRED when this block is present — where the handover file a fresh lead session
|
# handoverPath: REQUIRED when this block is present — where the handover file a fresh lead session
|
||||||
# reads must live. No default (an operator-specific path); a present block with no
|
# reads must live. No default (an operator-specific path); a present block with no
|
||||||
@@ -117,25 +118,30 @@ bind:
|
|||||||
# working directory when that lead has none configured) — never against whatever
|
# working directory when that lead has none configured) — never against whatever
|
||||||
# directory the daemon process happens to have been started in. An absolute path is
|
# directory the daemon process happens to have been started in. An absolute path is
|
||||||
# used unchanged. Prefer an absolute path if the daemon and the lead's pane might not
|
# used unchanged. Prefer an absolute path if the daemon and the lead's pane might not
|
||||||
# share a working directory (fleetd #480 follow-up).
|
# share a working directory.
|
||||||
# requireOperatorConfirm: true # default true — confirm() refuses unless the caller also passes
|
# requireOperatorConfirm: true # default true — confirm() refuses unless the caller also passes
|
||||||
# # operatorConfirmed: true
|
# # operatorConfirmed: true
|
||||||
# maxDocAgeSeconds: 3600 # default 3600 — refuse a handover file older than this
|
# maxDocAgeSeconds: 3600 # default 3600 — refuse a handover file older than this
|
||||||
# turnSettleSeconds: 20 # default 20 — how long the deferred roll waits for the CALLING
|
# turnSettleSeconds: 20 # default 20 — how long the deferred roll waits for the CALLING
|
||||||
# # lead's own turn to end (its pane to report injectable again)
|
# # lead's own turn to end (its pane to report injectable again)
|
||||||
# # before sending /clear at all. If this elapses, /clear is NEVER
|
# # before tearing the old pane down at all. If this elapses, nothing
|
||||||
# # sent — a lead that never goes idle is still doing real work.
|
# # is torn down — a lead that never goes idle is still doing real
|
||||||
# clearSettleSeconds: 20 # default 20 — how long to wait for the pane to become injectable
|
# # work.
|
||||||
# # again AFTER /clear before giving up (never sends bootstrapText
|
# relaunchReadySeconds: 45 # default 45 — bounds two later waits, after the old pane is gone
|
||||||
# # if this elapses). A separate, second wait from turnSettleSeconds.
|
# # and a fresh one has been launched: first, for the fresh pane to
|
||||||
|
# # reach a real turn boundary (never sends bootstrapText if THIS one
|
||||||
|
# # elapses); second, for the new terminal to be recognised as this
|
||||||
|
# # lead (bootstrapText is sent either way once the first wait
|
||||||
|
# # passes). A separate, later pair of waits from turnSettleSeconds.
|
||||||
# bootstrapText: "..." # default names the RESOLVED (absolute) handoverPath — sent to
|
# bootstrapText: "..." # default names the RESOLVED (absolute) handoverPath — sent to
|
||||||
# # the lead once its pane settles after /clear
|
# # the freshly relaunched lead's pane once it reaches a real turn
|
||||||
|
# # boundary
|
||||||
# leadRollover:
|
# leadRollover:
|
||||||
# handoverPath: /path/to/handover.md
|
# handoverPath: /path/to/handover.md
|
||||||
# requireOperatorConfirm: true
|
# requireOperatorConfirm: true
|
||||||
# maxDocAgeSeconds: 3600
|
# maxDocAgeSeconds: 3600
|
||||||
# turnSettleSeconds: 20
|
# turnSettleSeconds: 20
|
||||||
# clearSettleSeconds: 20
|
# relaunchReadySeconds: 45
|
||||||
# bootstrapText: "Fresh lead session: read the handover file and carry on."
|
# bootstrapText: "Fresh lead session: read the handover file and carry on."
|
||||||
|
|
||||||
# Fleet health detection is dormant unless enabled (CB-573). It reads one whole-fleet agent list
|
# Fleet health detection is dormant unless enabled (CB-573). It reads one whole-fleet agent list
|
||||||
@@ -659,9 +665,10 @@ fleet:
|
|||||||
# tabPrefix: "lead:" # only used to guard against a worker tabLabel colliding with
|
# tabPrefix: "lead:" # only used to guard against a worker tabLabel colliding with
|
||||||
# # this convention at startup; plays no part in matching a lead
|
# # this convention at startup; plays no part in matching a lead
|
||||||
# scanIntervalSeconds: 10 # rescan cadence, and the worst case before a new tab is seen
|
# scanIntervalSeconds: 10 # rescan cadence, and the worst case before a new tab is seen
|
||||||
# workspace: leads # where a launched lead's tab is created (default "leads").
|
# workspace: leads # where a launched lead's tab is created (default "fleet",
|
||||||
# # MUST NOT be a member workspace — those are excluded from the
|
# # the same shared space the members use). Sharing that space
|
||||||
# # scan, so a lead placed in one is never found again.
|
# # with members is the normal shipped shape: the scanner tells
|
||||||
|
# # a lead from a member by the exact tab label, not by workspace.
|
||||||
# cwd: /path/to/repo # the launched lead's working directory (default: fleetd's own)
|
# cwd: /path/to/repo # the launched lead's working directory (default: fleetd's own)
|
||||||
# kind: claude # descriptive; reported by fleet_whoami
|
# kind: claude # descriptive; reported by fleet_whoami
|
||||||
# gpt-sol-5.6:
|
# gpt-sol-5.6:
|
||||||
@@ -669,6 +676,22 @@ fleet:
|
|||||||
# kind: opencode
|
# kind: opencode
|
||||||
# model: openai/gpt-5.6-terra
|
# model: openai/gpt-5.6-terra
|
||||||
|
|
||||||
|
# A collaborator tab, keyed by name (fleetd #669). A pane whose tab matches resolves as the
|
||||||
|
# COLLABORATOR role: `fleet_whoami` answers `collaborator`, and the session may observe the fleet
|
||||||
|
# (`fleet_list`, `fleet_profiles`, `fleet_whoami`) and `fleet_send` to a lead or to another
|
||||||
|
# collaborator. It may NOT spawn, stop or drain anything, roll a lead's session, answer a
|
||||||
|
# member's `fleet_ask`, poll a ticket, send across hosts, or send to a spawned member's terminal.
|
||||||
|
# Each of those is refused at the gate rather than queued.
|
||||||
|
# Recognise-only, like a profile-less `leaders:` entry above: there is no `profile:`, no
|
||||||
|
# `instances:` and no `kind:`. `tab:` is REQUIRED and is the only field identity depends on,
|
||||||
|
# matched case-insensitively — the same GET-THE-VALUE-RIGHT warning above the `leaders:` block
|
||||||
|
# applies here too.
|
||||||
|
# A lead cannot discover a collaborator yet (#703): `fleet_list` has no `collaborators` key, so
|
||||||
|
# the collaborator must speak first, or pass on the `sessionId` its own `fleet_whoami` reports.
|
||||||
|
# collaborators:
|
||||||
|
# reviewer-alex:
|
||||||
|
# tab: "collab: alex"
|
||||||
|
|
||||||
# architects:
|
# architects:
|
||||||
# architect-1:
|
# architect-1:
|
||||||
# profile: opus # a strong model, on the operator's subscription
|
# profile: opus # a strong model, on the operator's subscription
|
||||||
|
|||||||
+4
-2
@@ -189,8 +189,10 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<!-- CB-634: the cutover renamed the module dir (bridged/ -> fleetd/), the jar, and the
|
<!-- CB-634: the cutover renamed the module dir (bridged/ -> fleetd/), the jar, and the
|
||||||
launchd plist together. The installed plist names fleetd/target/fleetd.jar and
|
launchd plist together. fleetd #664: the installed plist and the systemd unit now name
|
||||||
KeepAlive is armed, so this name, the plist, and the wrapper must move as one. -->
|
fleetd/run/fleetd.jar, not this plugin's own output path — see
|
||||||
|
scripts/redeploy-fleetd.sh for the mv that gets a build from here to there. KeepAlive is
|
||||||
|
armed, so this name, the plist, and the wrapper must still move as one. -->
|
||||||
<finalName>fleetd</finalName>
|
<finalName>fleetd</finalName>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|||||||
@@ -217,25 +217,27 @@ public final class Fleetd {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The {@link Injector}'s readiness gate (CB-534): a target is deliverable if it is a spawned
|
* The {@link Injector}'s readiness gate (CB-534): a target is deliverable if it is a spawned
|
||||||
* member whose agent has connected the bridge MCP, <em>or</em> a lead.
|
* member whose agent has connected the bridge MCP, a lead, <em>or</em> a collaborator.
|
||||||
*
|
*
|
||||||
* <p>The gate exists for one reason — to hold a delivery out of a <em>spawned</em> member's boot
|
* <p>The gate exists for one reason — to hold a delivery out of a <em>spawned</em> member's boot
|
||||||
* window, where herdr already reports {@code idle} but the TUI would drop an injected paste. That
|
* window, where herdr already reports {@code idle} but the TUI would drop an injected paste. That
|
||||||
* hazard is a property of spawning. A lead is never spawned: the operator started it and named it
|
* hazard is a property of spawning. A lead is never spawned: the operator started it and named it
|
||||||
* (or labelled its tab) only once it was up, so there is no boot window to guard.
|
* (or labelled its tab) only once it was up, so there is no boot window to guard. A collaborator
|
||||||
|
* is the same way — a person's own tab, matched to a configured name, never spawned.
|
||||||
*
|
*
|
||||||
* <p>A lead is also never enrolled in {@link MemberPresence} — {@code FleetMcp} marks presence
|
* <p>Neither a lead nor a collaborator is ever enrolled in {@link MemberPresence} — {@code
|
||||||
* for every spawned member (worker and architect), deliberately, since that map doubles as the
|
* FleetMcp} marks presence only for a worker, an architect, or the unconfigured-pane floor,
|
||||||
* member roster's availability signal and a lead counted there would show up as an available
|
* never for a lead or a collaborator. So without the second and third disjuncts a lead or
|
||||||
* member. So without the second disjunct a lead is permanently un-deliverable: every
|
* collaborator is permanently un-deliverable: every send to one sat on the gate for
|
||||||
* lead→lead send sat on the gate for {@code READINESS_GRACE_POLLS} (~60s) and then failed
|
* {@code READINESS_GRACE_POLLS} (~60s) and then failed having never been typed into the pane.
|
||||||
* having never been typed into the pane.
|
|
||||||
*
|
*
|
||||||
* <p>The lead set is read through the supplier on each call rather than snapshotted, so a lead
|
* <p>Both sets are read through their supplier on each call rather than snapshotted, so a lead or
|
||||||
* discovered by {@code leadScan} after startup becomes deliverable without a restart.
|
* 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,
|
||||||
return target -> presence.isPresent(target) || leads.get().containsKey(target);
|
Supplier<Map<String, String>> collaborators) {
|
||||||
|
return target -> presence.isPresent(target) || leads.get().containsKey(target)
|
||||||
|
|| collaborators.get().containsKey(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,22 +336,6 @@ public final class Fleetd {
|
|||||||
}, reasonByCredential::get);
|
}, reasonByCredential::get);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* fleetd #415 (review follow-up): package-private factory for the CB-578 stage A {@code
|
|
||||||
* exhaustedPattern} startup coverage line, paired explicitly with {@link
|
|
||||||
* CompletionResolver.UnsetMeaning#OFF} — {@code exhaustedPattern} has no fallback, so a
|
|
||||||
* profile with none configured really does have the classification off.
|
|
||||||
*
|
|
||||||
* <p>Extracted out of {@code main} for the same reason {@link #capacitySource} and {@link
|
|
||||||
* #worktreeBranchLookup} were: {@code coverage()}'s own tests ({@code CompletionResolverTest})
|
|
||||||
* prove it words {@code OFF} and {@link CompletionResolver.UnsetMeaning#BUILT_IN_DEFAULT}
|
|
||||||
* correctly when a test supplies the meaning itself — they cannot prove {@code main} pairs the
|
|
||||||
* right meaning with the right key, which is the actual fleetd #415 defect. <b>Measured:</b>
|
|
||||||
* swapping the {@code UnsetMeaning} arguments between this method and {@link
|
|
||||||
* #errorPatternCoverageLine} — recreating #415's defect with the two keys exchanged — compiled
|
|
||||||
* with 0 errors and left all 1506 existing tests green before {@code
|
|
||||||
* FleetdPatternCoverageLineTest} was added to catch exactly that swap.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* fleetd #446 follow-up: the criterion-2 WARNING text — "name the fix, not just the fact" —
|
* fleetd #446 follow-up: the criterion-2 WARNING text — "name the fix, not just the fact" —
|
||||||
* for a profile whose {@code model:} is configured. Extracted out of the {@code
|
* for a profile whose {@code model:} is configured. Extracted out of the {@code
|
||||||
@@ -384,19 +370,22 @@ public final class Fleetd {
|
|||||||
+ "s quarantine above is the only thing keeping new spawns off it for now";
|
+ "s quarantine above is the only thing keeping new spawns off it for now";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-private factory for the {@code exhaustedPattern} startup coverage line, paired
|
||||||
|
* explicitly with {@link CompletionResolver.UnsetMeaning#OFF} — {@code exhaustedPattern} has
|
||||||
|
* no fallback, so a profile with none configured really does have the classification off.
|
||||||
|
*/
|
||||||
static String exhaustedPatternCoverageLine(Set<String> allProfiles, Set<String> configuredProfiles) {
|
static String exhaustedPatternCoverageLine(Set<String> allProfiles, Set<String> configuredProfiles) {
|
||||||
return CompletionResolver.coverage("exhaustedPattern", CompletionResolver.UnsetMeaning.OFF,
|
return CompletionResolver.coverage("exhaustedPattern", CompletionResolver.UnsetMeaning.OFF,
|
||||||
allProfiles, configuredProfiles);
|
allProfiles, configuredProfiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fleetd #415 (review follow-up): the {@code errorPattern} counterpart of {@link
|
* The {@code errorPattern} counterpart of {@link #exhaustedPatternCoverageLine}, paired
|
||||||
* #exhaustedPatternCoverageLine}, paired explicitly with {@link
|
* explicitly with {@link CompletionResolver.UnsetMeaning#BUILT_IN_DEFAULT} — an unset
|
||||||
* CompletionResolver.UnsetMeaning#BUILT_IN_DEFAULT} — an unset {@code errorPattern} still runs
|
* {@code errorPattern} still runs backend-error classification against
|
||||||
* backend-error classification against {@code CompletionResolver}'s built-in {@code
|
* {@code CompletionResolver}'s built-in {@code BACKEND_ERROR} pattern, so the empty case is
|
||||||
* BACKEND_ERROR} pattern, so the empty case is not "off". See {@link
|
* not "off".
|
||||||
* #exhaustedPatternCoverageLine}'s javadoc for the measured swap mutation this pairing guards
|
|
||||||
* against.
|
|
||||||
*/
|
*/
|
||||||
static String errorPatternCoverageLine(Set<String> allProfiles, Set<String> configuredProfiles) {
|
static String errorPatternCoverageLine(Set<String> allProfiles, Set<String> configuredProfiles) {
|
||||||
return CompletionResolver.coverage("errorPattern", CompletionResolver.UnsetMeaning.BUILT_IN_DEFAULT,
|
return CompletionResolver.coverage("errorPattern", CompletionResolver.UnsetMeaning.BUILT_IN_DEFAULT,
|
||||||
@@ -732,8 +721,8 @@ public final class Fleetd {
|
|||||||
* {@code CompletionResolver} constructor call — provably untested wiring, the whole reason
|
* {@code CompletionResolver} constructor call — provably untested wiring, the whole reason
|
||||||
* fleetd #248 exists: dropping that one argument (passing {@code _ -> null} instead) compiled
|
* fleetd #248 exists: dropping that one argument (passing {@code _ -> null} instead) compiled
|
||||||
* clean and left every test green. Extracted here, {@code main} now calls this factory instead
|
* clean and left every test green. Extracted here, {@code main} now calls this factory instead
|
||||||
* of building the lambda inline, and a source assertion on that call site
|
* of building the lambda inline, so the argument reaching the {@code CompletionResolver}
|
||||||
* ({@code FleetdCompletionResolverWiringTest}) proves the argument is still actually passed.
|
* constructor is a named, directly testable call rather than an inline lambda.
|
||||||
*
|
*
|
||||||
* <p>Takes the roster as a plain {@link Supplier} — not a {@link SessionManager} — so this is
|
* <p>Takes the roster as a plain {@link Supplier} — not a {@link SessionManager} — so this is
|
||||||
* directly testable with a hand-built session list; no real {@code SessionManager} (launcher,
|
* directly testable with a hand-built session list; no real {@code SessionManager} (launcher,
|
||||||
@@ -902,6 +891,32 @@ public final class Fleetd {
|
|||||||
throw (T) t;
|
throw (T) t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link PrimaryRegistry} lookup for "which terminal currently hosts the lead named
|
||||||
|
* {@code name}" — the inverse of {@code liveLeadTerminals} (terminal id → lead name), read live
|
||||||
|
* on every call so a lead discovered, rolled, or lost since the last call is reflected without
|
||||||
|
* a restart. Returns {@code null} when no currently recognised lead carries that name — a name
|
||||||
|
* that is not a lead at all (an architect slot, a collaborator), or a lead whose tab the scan
|
||||||
|
* cannot currently place (just rolled, off-host, non-herdr).
|
||||||
|
*
|
||||||
|
* @param liveLeadTerminals terminal id → lead name for every CURRENTLY recognised lead, normally
|
||||||
|
* the same {@code leads} supplier {@code main} already builds for
|
||||||
|
* {@code HerdrRouter}/{@link #leadSeatLookup}
|
||||||
|
*/
|
||||||
|
static Function<String, String> currentTerminalForName(Supplier<Map<String, String>> liveLeadTerminals) {
|
||||||
|
return name -> {
|
||||||
|
if (name == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (var entry : liveLeadTerminals.get().entrySet()) {
|
||||||
|
if (name.equals(entry.getValue())) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fleetd #480: construct the {@link LeadRollover} executor only when {@code leadRollover:} is
|
* fleetd #480: construct the {@link LeadRollover} executor only when {@code leadRollover:} is
|
||||||
* present at startup — the same presence gate {@code leadHeartbeat:} uses just above this
|
* present at startup — the same presence gate {@code leadHeartbeat:} uses just above this
|
||||||
@@ -939,21 +954,27 @@ public final class Fleetd {
|
|||||||
* cfg.leadHeartbeat()}
|
* cfg.leadHeartbeat()}
|
||||||
* @param leadAgents the {@link AgentControl} instance that reaches the LEAD's pane (not
|
* @param leadAgents the {@link AgentControl} instance that reaches the LEAD's pane (not
|
||||||
* {@code memberAgents}), normally {@code router.leadAgents()}
|
* {@code memberAgents}), normally {@code router.leadAgents()}
|
||||||
|
* @param leadSpaces the {@link WorkspaceControl} instance that reaches the LEAD's
|
||||||
|
* workspace, normally {@code router.leadSpaces()} — used to tear down
|
||||||
|
* a rolled lead's old pane and confirm it is gone
|
||||||
|
* @param launcher starts the fresh lead a roll relaunches once the old one is gone
|
||||||
* @param config the live {@link ConfigRef}, captured only inside the returned
|
* @param config the live {@link ConfigRef}, captured only inside the returned
|
||||||
* supplier and the workspace lookup — never dereferenced here
|
* supplier and the two lookups below — never dereferenced here
|
||||||
* @param liveLeadTerminals terminal id → lead NAME for every CURRENTLY recognised lead, normally
|
* @param liveLeadTerminals terminal id → lead NAME for every CURRENTLY recognised lead, normally
|
||||||
* the same {@code leads} supplier {@code main} already builds for
|
* the same {@code leads} supplier {@code main} already builds for
|
||||||
* {@code HerdrRouter}/{@link #leadSeatLookup} — never a value snapshot
|
* {@code HerdrRouter}/{@link #leadSeatLookup} — never a value snapshot
|
||||||
* @return a constructed {@link LeadRollover}, or {@code null} when {@code leadRollover:} is
|
* @return a constructed {@link LeadRollover}, or {@code null} when {@code leadRollover:} is
|
||||||
* absent from the startup config
|
* absent from the startup config
|
||||||
*/
|
*/
|
||||||
static LeadRollover leadRollover(FleetConfig cfg, AgentControl leadAgents, ConfigRef config,
|
static LeadRollover leadRollover(FleetConfig cfg, AgentControl leadAgents,
|
||||||
|
WorkspaceControl leadSpaces, LeadLauncher launcher, ConfigRef config,
|
||||||
Supplier<Map<String, String>> liveLeadTerminals) {
|
Supplier<Map<String, String>> liveLeadTerminals) {
|
||||||
if (cfg.leadRollover() == null) {
|
if (cfg.leadRollover() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
Function<String, String> leadNameForTerminal = terminal -> liveLeadTerminals.get().get(terminal);
|
||||||
Function<String, String> leadWorkspace = terminal -> {
|
Function<String, String> leadWorkspace = terminal -> {
|
||||||
String leadName = liveLeadTerminals.get().get(terminal);
|
String leadName = leadNameForTerminal.apply(terminal);
|
||||||
if (leadName == null) {
|
if (leadName == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -961,7 +982,8 @@ public final class Fleetd {
|
|||||||
FleetConfig.Leader leader = fleet == null ? null : fleet.leaders().get(leadName);
|
FleetConfig.Leader leader = fleet == null ? null : fleet.leaders().get(leadName);
|
||||||
return leader == null ? null : leader.cwd();
|
return leader == null ? null : leader.cwd();
|
||||||
};
|
};
|
||||||
return new LeadRollover(leadAgents, () -> config.get().leadRollover(), leadWorkspace);
|
return new LeadRollover(leadAgents, leadSpaces, launcher, () -> config.get().leadRollover(),
|
||||||
|
leadWorkspace, leadNameForTerminal, liveLeadTerminals);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import dev.ltms.fleet.msg.MessageService;
|
|||||||
import dev.ltms.fleet.msg.Rendezvous;
|
import dev.ltms.fleet.msg.Rendezvous;
|
||||||
import dev.ltms.fleet.msg.ReplyInbox;
|
import dev.ltms.fleet.msg.ReplyInbox;
|
||||||
import dev.ltms.fleet.msg.ReplyPushLoop;
|
import dev.ltms.fleet.msg.ReplyPushLoop;
|
||||||
|
import dev.ltms.fleet.peer.MemberRole;
|
||||||
import dev.ltms.fleet.peer.PeerLauncher;
|
import dev.ltms.fleet.peer.PeerLauncher;
|
||||||
import dev.ltms.fleet.placement.BackendOutagePolicy;
|
import dev.ltms.fleet.placement.BackendOutagePolicy;
|
||||||
import dev.ltms.fleet.placement.BackendQuarantine;
|
import dev.ltms.fleet.placement.BackendQuarantine;
|
||||||
@@ -141,8 +142,12 @@ final class FleetdAssembly {
|
|||||||
? ports.connectHerdr(Path.of(cfg.memberHerdrSocket()))
|
? ports.connectHerdr(Path.of(cfg.memberHerdrSocket()))
|
||||||
: herdr;
|
: herdr;
|
||||||
AtomicReference<Supplier<Map<String, String>>> leadsRef = new AtomicReference<>(Map::of);
|
AtomicReference<Supplier<Map<String, String>>> leadsRef = new AtomicReference<>(Map::of);
|
||||||
|
// fleetd #669 Unit E: a collaborator's pane is opened by a person, exactly like a lead's,
|
||||||
|
// so its terminal must also route to the lead herdr daemon rather than the member one.
|
||||||
|
AtomicReference<Supplier<Map<String, String>>> collaboratorTerminalsRef = new AtomicReference<>(Map::of);
|
||||||
HerdrRouter router = new HerdrRouter(herdr, memberHerdr,
|
HerdrRouter router = new HerdrRouter(herdr, memberHerdr,
|
||||||
target -> leadsRef.get().get().containsKey(target));
|
target -> leadsRef.get().get().containsKey(target)
|
||||||
|
|| collaboratorTerminalsRef.get().get().containsKey(target));
|
||||||
// CB-402: one adapter per configured peer kind, fronted by a composite router. A profile's
|
// CB-402: one adapter per configured peer kind, fronted by a composite router. A profile's
|
||||||
// `kind:` selects its adapter — claude-code (the default) and opencode partition the profile
|
// `kind:` selects its adapter — claude-code (the default) and opencode partition the profile
|
||||||
// set — and the composite dispatches each SPI call to the adapter that owns the profile/pane.
|
// set — and the composite dispatches each SPI call to the adapter that owns the profile/pane.
|
||||||
@@ -249,33 +254,68 @@ final class FleetdAssembly {
|
|||||||
if (leadTerminals.size() > 1) {
|
if (leadTerminals.size() > 1) {
|
||||||
log.info("leads: {} panes recognised {}", leadTerminals.size(), leadTerminals.values());
|
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
|
// Discover leads by their tab labels, one scanner per configured lead's own space. fleetd
|
||||||
// configured lead's own exact `tab:` label.
|
// #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>> leads;
|
||||||
|
final Supplier<Map<String, String>> collaboratorTerminals;
|
||||||
var leaders = cfg.fleet().leaders();
|
var leaders = cfg.fleet().leaders();
|
||||||
if (!leaders.isEmpty()) {
|
var collaboratorsConfig = cfg.fleet().collaborators();
|
||||||
Map<String, String> tabToName = new LinkedHashMap<>();
|
if (!leaders.isEmpty() || !collaboratorsConfig.isEmpty()) {
|
||||||
|
Map<String, Map<String, String>> leadLabelsBySpace = new LinkedHashMap<>();
|
||||||
|
Map<String, String> spaceByLeadName = new LinkedHashMap<>();
|
||||||
leaders.forEach((name, leader) -> {
|
leaders.forEach((name, leader) -> {
|
||||||
if (leader != null && leader.tab() != null && !leader.tab().isBlank()) {
|
if (leader == null) {
|
||||||
tabToName.put(leader.tab(), name);
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
int scanIntervalSeconds = leaders.values().iterator().next().scanIntervalSeconds();
|
Map<String, String> collaboratorTabToName = new LinkedHashMap<>();
|
||||||
|
collaboratorsConfig.forEach((name, collaborator) -> {
|
||||||
|
if (collaborator != null && collaborator.tab() != null && !collaborator.tab().isBlank()) {
|
||||||
|
collaboratorTabToName.put(collaborator.tab(), name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// A collaborator-only fleet configures no `leaders:` entry to read a scan interval from
|
||||||
|
// — FleetConfig.Collaborator carries no scanIntervalSeconds of its own. Falling back to
|
||||||
|
// FleetConfig.Leader's own compact-constructor default keeps a collaborator-only
|
||||||
|
// deployment on the same rescan cadence as the default lead cadence, instead of
|
||||||
|
// inventing a second number for the same kind of scan.
|
||||||
|
int scanIntervalSeconds = leaders.isEmpty()
|
||||||
|
? 10
|
||||||
|
: leaders.values().iterator().next().scanIntervalSeconds();
|
||||||
// This must use the lead daemon: scanning member tabs would demote the lead to a worker.
|
// This must use the lead daemon: scanning member tabs would demote the lead to a worker.
|
||||||
leads = new LeadTabScanner(herdr, tabToName, Set.of(),
|
LeadTabScanner scanner = new LeadTabScanner(herdr, leadLabelsBySpace, collaboratorTabToName,
|
||||||
TimeUnit.SECONDS.toNanos(scanIntervalSeconds), ports.nanoClock());
|
Set.of(), TimeUnit.SECONDS.toNanos(scanIntervalSeconds), ports.nanoClock());
|
||||||
log.info("lead scan: tabs {} host a lead (rescan every {}s, shared fleet space)",
|
leads = scanner;
|
||||||
tabToName.keySet(), scanIntervalSeconds);
|
collaboratorTerminals = scanner::collaborators;
|
||||||
|
log.info("lead/collaborator scan: space per lead {}, tabs {} host a collaborator "
|
||||||
|
+ "(rescan every {}s)",
|
||||||
|
spaceByLeadName, collaboratorTabToName.keySet(), scanIntervalSeconds);
|
||||||
} else {
|
} else {
|
||||||
leads = () -> leadTerminals;
|
leads = () -> leadTerminals;
|
||||||
|
collaboratorTerminals = Map::of;
|
||||||
}
|
}
|
||||||
leadsRef.set(leads);
|
leadsRef.set(leads);
|
||||||
|
collaboratorTerminalsRef.set(collaboratorTerminals);
|
||||||
|
|
||||||
|
// Constructed unconditionally — it is cheap and side-effect free — so a LeadRollover built
|
||||||
|
// below can relaunch a lead even on a boot where herdr was down for the ensureLeads() call.
|
||||||
|
LeadLauncher leadLauncher = new LeadLauncher(router.leadAgents(), router.leadSpaces(), cfg);
|
||||||
|
|
||||||
// CB-558: start any declared lead that is not already running. After the scanner is built,
|
// CB-558: start any declared lead that is not already running. After the scanner is built,
|
||||||
// and only when herdr answered — the launcher's whole safety property is that it can count
|
// and only when herdr answered — the launcher's whole safety property is that it can count
|
||||||
// live leads first, and must never guess and risk a second orchestrator.
|
// live leads first, and must never guess and risk a second orchestrator.
|
||||||
if (herdrUp && !leaders.isEmpty()) {
|
if (herdrUp && !leaders.isEmpty()) {
|
||||||
int launched = new LeadLauncher(router.leadAgents(), router.leadSpaces(), cfg).ensureLeads();
|
int launched = leadLauncher.ensureLeads();
|
||||||
if (launched > 0) {
|
if (launched > 0) {
|
||||||
log.info("lead auto-launch: {} lead(s) started", launched);
|
log.info("lead auto-launch: {} lead(s) started", launched);
|
||||||
}
|
}
|
||||||
@@ -341,7 +381,7 @@ final class FleetdAssembly {
|
|||||||
// CB-301: the manager's presence bridge records availability and drives SPAWNING → READY.
|
// CB-301: the manager's presence bridge records availability and drives SPAWNING → READY.
|
||||||
MemberPresence presence = sessions.asPresence();
|
MemberPresence presence = sessions.asPresence();
|
||||||
TurnListener turnListener = Fleetd.turnListener(completion, sessions);
|
TurnListener turnListener = Fleetd.turnListener(completion, sessions);
|
||||||
Predicate<String> deliverable = Fleetd.deliverableTo(presence, leads);
|
Predicate<String> deliverable = Fleetd.deliverableTo(presence, leads, collaboratorTerminals);
|
||||||
// fleetd #556: registration is wired directly to `completion`, not folded into the
|
// fleetd #556: registration is wired directly to `completion`, not folded into the
|
||||||
// `turnListener` fan-out above — so it survives `sessions.onDelivered` (or any future
|
// `turnListener` fan-out above — so it survives `sessions.onDelivered` (or any future
|
||||||
// listener) throwing, regardless of call order.
|
// listener) throwing, regardless of call order.
|
||||||
@@ -363,7 +403,8 @@ final class FleetdAssembly {
|
|||||||
ports.leadMailboxOpener());
|
ports.leadMailboxOpener());
|
||||||
// CB-307: learn the primary's terminal from orchestration tool calls (or pin from config).
|
// CB-307: learn the primary's terminal from orchestration tool calls (or pin from config).
|
||||||
String pinnedPrimaryTerminal = cfg.primary() != null ? cfg.primary().terminal() : null;
|
String pinnedPrimaryTerminal = cfg.primary() != null ? cfg.primary().terminal() : null;
|
||||||
PrimaryRegistry primaryRegistry = new PrimaryRegistry(pinnedPrimaryTerminal);
|
PrimaryRegistry primaryRegistry = new PrimaryRegistry(pinnedPrimaryTerminal,
|
||||||
|
Fleetd.currentTerminalForName(leads));
|
||||||
// CB-532: `primary.terminal` is superseded and no longer needed for either of its jobs.
|
// CB-532: `primary.terminal` is superseded and no longer needed for either of its jobs.
|
||||||
if (pinnedPrimaryTerminal != null && !pinnedPrimaryTerminal.isBlank()) {
|
if (pinnedPrimaryTerminal != null && !pinnedPrimaryTerminal.isBlank()) {
|
||||||
log.warn("primary.terminal is DEPRECATED (CB-532) and can be deleted: identity now comes "
|
log.warn("primary.terminal is DEPRECATED (CB-532) and can be deleted: identity now comes "
|
||||||
@@ -414,7 +455,8 @@ final class FleetdAssembly {
|
|||||||
heartbeatScheduler.shutdownNow();
|
heartbeatScheduler.shutdownNow();
|
||||||
}
|
}
|
||||||
// fleetd #480: lead rollover. Opt-in; absent `leadRollover:` this is never constructed.
|
// fleetd #480: lead rollover. Opt-in; absent `leadRollover:` this is never constructed.
|
||||||
LeadRollover leadRollover = Fleetd.leadRollover(cfg, router.leadAgents(), config, leads);
|
LeadRollover leadRollover = Fleetd.leadRollover(cfg, router.leadAgents(), router.leadSpaces(),
|
||||||
|
leadLauncher, config, leads);
|
||||||
MessageService messages = new MessageService(router, injector, rendezvous, replyInbox,
|
MessageService messages = new MessageService(router, injector, rendezvous, replyInbox,
|
||||||
pushLoop, metrics);
|
pushLoop, metrics);
|
||||||
|
|
||||||
@@ -452,6 +494,11 @@ final class FleetdAssembly {
|
|||||||
ConnectionIdentity identity = new ConnectionIdentity(
|
ConnectionIdentity identity = new ConnectionIdentity(
|
||||||
new PaneLocator(herdr, memberHerdr), new LsofPeerPidLookup(), new LsofProcessCwdLookup());
|
new PaneLocator(herdr, memberHerdr), new LsofPeerPidLookup(), new LsofProcessCwdLookup());
|
||||||
|
|
||||||
|
// fleetd #669 Unit D: a live spawned member resolves as its own role, whatever a tab map
|
||||||
|
// says about the same terminal. fleetd #702: SessionManager.spawnedMemberRole also answers
|
||||||
|
// for a pane mid-teardown, not only one still in the registry — see its javadoc.
|
||||||
|
Function<String, MemberRole> spawnedMemberRole = sessions::spawnedMemberRole;
|
||||||
|
|
||||||
// CB-501: one resolver behind both entry paths. Worker identity still comes from the
|
// CB-501: one resolver behind both entry paths. Worker identity still comes from the
|
||||||
// connection and is never token-gated, so enabling token mode cannot lock the fleet out.
|
// connection and is never token-gated, so enabling token mode cannot lock the fleet out.
|
||||||
final CallerResolver callers;
|
final CallerResolver callers;
|
||||||
@@ -461,11 +508,13 @@ final class FleetdAssembly {
|
|||||||
throw new IllegalStateException("auth.mode=token but env var " + cfg.auth().tokenEnv()
|
throw new IllegalStateException("auth.mode=token but env var " + cfg.auth().tokenEnv()
|
||||||
+ " is unset or empty — export it before starting fleetd");
|
+ " is unset or empty — export it before starting fleetd");
|
||||||
}
|
}
|
||||||
callers = CallerResolver.withLeadsAndMembers(identity, true, token, leads, members);
|
callers = CallerResolver.withLeadsAndMembers(identity, true, token, leads, members,
|
||||||
|
spawnedMemberRole, collaboratorTerminals);
|
||||||
log.info("auth: token mode (bearer required for non-worker callers, env {})",
|
log.info("auth: token mode (bearer required for non-worker callers, env {})",
|
||||||
cfg.auth().tokenEnv());
|
cfg.auth().tokenEnv());
|
||||||
} else {
|
} else {
|
||||||
callers = CallerResolver.withLeadsAndMembers(identity, false, null, leads, members);
|
callers = CallerResolver.withLeadsAndMembers(identity, false, null, leads, members,
|
||||||
|
spawnedMemberRole, collaboratorTerminals);
|
||||||
log.info("auth: loopback-trust (any loopback non-worker caller is the primary)");
|
log.info("auth: loopback-trust (any loopback non-worker caller is the primary)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.ltms.fleet.auth;
|
package dev.ltms.fleet.auth;
|
||||||
|
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The authorization table (CB-505), stated once and enforced on both entry paths.
|
* The authorization table (CB-505), stated once and enforced on both entry paths.
|
||||||
*
|
*
|
||||||
@@ -20,16 +22,29 @@ public final class Authz {
|
|||||||
SPAWN,
|
SPAWN,
|
||||||
/** Tear a worker peer down. */
|
/** Tear a worker peer down. */
|
||||||
STOP,
|
STOP,
|
||||||
/** Deliver a turn to a session (or answer a worker's question). */
|
/** Deliver a turn to a local session, addressed by {@code sessionId}. */
|
||||||
SEND,
|
SEND,
|
||||||
|
/** Resolve a worker's blocked question and resume its turn, addressed by {@code turnId}. */
|
||||||
|
ANSWER,
|
||||||
|
/** Address a peer lead on another daemon over the coordination broker, by {@code coordId}. */
|
||||||
|
COORD_SEND,
|
||||||
/** A worker's terminal reply for its own turn. */
|
/** A worker's terminal reply for its own turn. */
|
||||||
REPLY,
|
REPLY,
|
||||||
/** A worker's mid-turn question to the primary. */
|
/** A worker's mid-turn question to the primary. */
|
||||||
ASK,
|
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. */
|
/** Collect held replies from a session's inbox. */
|
||||||
DRAIN,
|
DRAIN,
|
||||||
/** Read-only observation: status, roster, profiles, task polling. */
|
/** Read-only roster, profile, and identity observation: no ticket, task, or turn state. */
|
||||||
READ,
|
READ,
|
||||||
|
/** Poll a ticket, or read a session's status. */
|
||||||
|
TASK_READ,
|
||||||
/**
|
/**
|
||||||
* Read (never ack) this daemon's own held lead-to-lead coordination mail (fleetd #421).
|
* Read (never ack) this daemon's own held lead-to-lead coordination mail (fleetd #421).
|
||||||
*
|
*
|
||||||
@@ -54,43 +69,127 @@ public final class Authz {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether {@code caller} may perform {@code action} against {@code targetSession}.
|
* The fail-closed classifier: answers no for every target, so a collaborator's {@code SEND}
|
||||||
|
* is refused unless a caller supplies a real one. {@code CallerResolver#knownLeadOrCollaborator()}
|
||||||
|
* is the real one, read from the same lead and collaborator maps {@code CallerResolver#resolve}
|
||||||
|
* consults, so a target that classifier calls known is one {@code resolve} would actually
|
||||||
|
* resolve as a lead or collaborator.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* 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.
|
||||||
*
|
*
|
||||||
* @param targetSession the session id in the request path; only consulted for the worker-scoped
|
* <p>Its default classifiers deny every collaborator and every observer, so a caller
|
||||||
* actions ({@code REPLY}, {@code ASK}), ignored otherwise, may be
|
* enforcing authorization must use the five-argument form instead.
|
||||||
* {@code null}
|
|
||||||
*/
|
*/
|
||||||
public static boolean permits(Principal caller, Action action, String targetSession) {
|
public static boolean permits(Principal caller, Action action, String targetSession) {
|
||||||
|
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},
|
||||||
|
* {@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> observerSendTarget) {
|
||||||
if (caller == null || caller.isAnonymous()) {
|
if (caller == null || caller.isAnonymous()) {
|
||||||
return false; // authenticated as nothing ⇒ authorized for nothing
|
return false; // authenticated as nothing ⇒ authorized for nothing
|
||||||
}
|
}
|
||||||
return switch (action) {
|
return switch (action) {
|
||||||
// Fleet lifecycle is the primary's alone — spawn, stop, drain. An architect
|
// Fleet lifecycle is the primary's alone — spawn, stop, drain. An architect and a
|
||||||
// deliberately does NOT get these (CB-548), so it cannot tear down or stand up workers
|
// collaborator deliberately do NOT get these, so neither can tear down or stand up
|
||||||
// even though it coordinates them; and a worker driving any of these would be a worker
|
// workers even though one of them coordinates them; and a worker driving any of these
|
||||||
// escalating into the orchestrator role.
|
// would be a worker escalating into the orchestrator role.
|
||||||
case SPAWN, STOP, DRAIN, HANDOVER -> caller.isPrimary();
|
case SPAWN, STOP, DRAIN, HANDOVER -> caller.isPrimary();
|
||||||
|
|
||||||
// Delivering a turn is open to the primary and the architect: an architect delegates
|
// Delivering a turn to a local session is open to the primary and the architect
|
||||||
// to workers (that is the role's point) but still has no lifecycle rights. A worker is
|
// unconditionally. A collaborator may reach only a target that is itself a configured
|
||||||
// excluded — sending would be it escalating.
|
// lead or collaborator, never a spawned member's terminal. An observer may reach only
|
||||||
case SEND -> caller.isPrimary() || caller.isArchitect();
|
// 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.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:
|
||||||
|
// resuming another session's turn is lifecycle-adjacent, not peer messaging.
|
||||||
|
case ANSWER -> caller.isPrimary() || caller.isArchitect();
|
||||||
|
|
||||||
|
// Leaves the daemon over the coordination broker rather than addressing a local
|
||||||
|
// session, open to the same two roles as ANSWER. Not a collaborator: it is a
|
||||||
|
// local-tab peer with no cross-host route.
|
||||||
|
case COORD_SEND -> caller.isPrimary() || caller.isArchitect();
|
||||||
|
|
||||||
// The load-bearing rule: a caller acts only as the pane it occupies. CB-532 widened who
|
// The load-bearing rule: a caller acts only as the pane it occupies. CB-532 widened who
|
||||||
// that can be — a lead answering another lead is replying for its OWN terminal, which
|
// that can be — a lead answering another lead is replying for its OWN terminal, which
|
||||||
// this already permits — while the rule itself is unchanged, and is what stops anyone
|
// this already permits — while the rule itself is unchanged, and is what stops anyone
|
||||||
// forging a reply for a rendezvous someone else is waiting on. An architect's own pane
|
// forging a reply for a rendezvous someone else is waiting on. An architect's or a
|
||||||
// passes through the same check, so it can answer a funnel that delegated to it. An
|
// collaborator's own pane passes through the same check, so each can answer a funnel
|
||||||
// unnamed primary (token/loopback, no pane) owns nothing and is still excluded.
|
// that delegated to it. An unnamed primary (token/loopback, no pane) owns nothing and
|
||||||
case REPLY, ASK -> caller.ownsSession(targetSession);
|
// is still excluded.
|
||||||
|
case REPLY, ASK, INBOX -> caller.ownsSession(targetSession);
|
||||||
|
|
||||||
// Observation is open to every authenticated role: a worker legitimately polls its own
|
// READ is roster, profile, and identity observation — fleet_list, fleet_profiles, and
|
||||||
// status, and the roster carries no secrets.
|
// fleet_whoami — and carries no secrets: no ticket reply, no pending question, and no
|
||||||
case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect();
|
// other session's turn state. Those live under TASK_READ. METRICS is the separate
|
||||||
|
// Prometheus scrape. Both are open to every authenticated role, including a
|
||||||
|
// collaborator and the unconfigured-pane floor.
|
||||||
|
case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect()
|
||||||
|
|| caller.isCollaborator() || caller.isObserver();
|
||||||
|
|
||||||
|
// Ticket polling and session status, open to every role READ is open to except a
|
||||||
|
// collaborator or an observer. MessageService compares a ticket's creator to the
|
||||||
|
// caller on every read as well, so dropping this gate would not expose another
|
||||||
|
// session's reply — it would move the refusal later and widen what a caller that
|
||||||
|
// never orchestrates can probe.
|
||||||
|
case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect();
|
||||||
|
|
||||||
// fleetd #421: reading held lead-to-lead mail is the primary's alone. An architect
|
// fleetd #421: reading held lead-to-lead mail is the primary's alone. An architect
|
||||||
// holds READ today (CB-548), so "not primary" must mean not-architect here too — this
|
// holds READ today (CB-548), so "not primary" must mean not-architect here too — this
|
||||||
// is coordination between leads, not observation of the roster.
|
// is coordination between leads, not observation of the roster. The same reasoning
|
||||||
|
// excludes a collaborator.
|
||||||
case COORD_READ -> caller.isPrimary();
|
case COORD_READ -> caller.isPrimary();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,13 @@ import java.util.function.Supplier;
|
|||||||
*
|
*
|
||||||
* <p><strong>Resolution order</strong> — connection identity first, token second, nothing third:
|
* <p><strong>Resolution order</strong> — connection identity first, token second, nothing third:
|
||||||
* <ol>
|
* <ol>
|
||||||
|
* <li>A loopback peer PID that maps to a pane this gateway itself spawned ⇒ that member's own
|
||||||
|
* role: {@link Role#WORKER} for a dev, hunter, or reviewer; {@link Role#ARCHITECT} for an
|
||||||
|
* architect, but only while the live slot role still confirms it (fleetd #424 — a slot
|
||||||
|
* revoked from config demotes an already-bound session on its very next request, so the
|
||||||
|
* roster's own role is never granted on its word alone). No tab map is consulted — a live
|
||||||
|
* spawned member's identity comes from the registry that spawned it, never from a label a
|
||||||
|
* pane could also carry.</li>
|
||||||
* <li>A loopback peer PID that maps to a pane named by {@code leaders:}, by the legacy
|
* <li>A loopback peer PID that maps to a pane named by {@code leaders:}, by the legacy
|
||||||
* {@code primary.terminal} pin, or by an operator-labelled lead tab (CB-307, CB-530, CB-531)
|
* {@code primary.terminal} pin, or by an operator-labelled lead tab (CB-307, CB-530, CB-531)
|
||||||
* ⇒ {@link Role#PRIMARY}, carrying that lead's
|
* ⇒ {@link Role#PRIMARY}, carrying that lead's
|
||||||
@@ -28,9 +36,11 @@ import java.util.function.Supplier;
|
|||||||
* so two leads can work as peers rather than one being demoted.</li>
|
* so two leads can work as peers rather than one being demoted.</li>
|
||||||
* <li>A loopback peer PID that maps to a pane bound to a CB-548 architect slot ⇒
|
* <li>A loopback peer PID that maps to a pane bound to a CB-548 architect slot ⇒
|
||||||
* {@link Role#ARCHITECT}, carrying the slot name. Just unforgeable as a worker's, and
|
* {@link Role#ARCHITECT}, carrying the slot name. Just unforgeable as a worker's, and
|
||||||
* resolved from the <em>live</em> terminal→slot binding (never a request argument), before
|
* resolved from the <em>live</em> terminal→slot binding (never a request argument). This is
|
||||||
* the generic worker fallback.</li>
|
* the case the previous step does not catch: a binding with no live spawned-member session.</li>
|
||||||
* <li>A loopback peer PID that maps to any other herdr pane ⇒ {@link Role#WORKER}. This is
|
* <li>A loopback peer PID that maps to an operator-labelled collaborator tab ⇒
|
||||||
|
* {@link Role#COLLABORATOR}, carrying that collaborator's name.</li>
|
||||||
|
* <li>A loopback peer PID that maps to any other herdr pane ⇒ {@link Role#OBSERVER}. This is
|
||||||
* unforgeable (the OS reports the PID, herdr owns the PID→pane map) and is honoured
|
* unforgeable (the OS reports the PID, herdr owns the PID→pane map) and is honoured
|
||||||
* regardless of auth mode, so enabling auth never breaks the fleet.</li>
|
* regardless of auth mode, so enabling auth never breaks the fleet.</li>
|
||||||
* <li>Otherwise, under {@code token} mode, a valid bearer token ⇒ {@link Role#PRIMARY}.</li>
|
* <li>Otherwise, under {@code token} mode, a valid bearer token ⇒ {@link Role#PRIMARY}.</li>
|
||||||
@@ -64,6 +74,20 @@ public final class CallerResolver {
|
|||||||
private final Supplier<Map<String, String>> architectTerminals;
|
private final Supplier<Map<String, String>> architectTerminals;
|
||||||
private final Function<String, MemberRole> memberSlotRoles;
|
private final Function<String, MemberRole> memberSlotRoles;
|
||||||
private final Function<String, String> memberSlotNames;
|
private final Function<String, String> memberSlotNames;
|
||||||
|
/**
|
||||||
|
* terminal_id → the role of the live spawned member occupying it, or {@code null} for a
|
||||||
|
* terminal no spawned member occupies. Consulted first, ahead of every tab map: a live
|
||||||
|
* spawned member's identity is its own, whatever a tab map says about the same terminal.
|
||||||
|
* A function rather than the roster itself, so a resolve on the hot path never scans a list —
|
||||||
|
* the lookup strategy is the caller's to choose.
|
||||||
|
*/
|
||||||
|
private final Function<String, MemberRole> spawnedMemberRole;
|
||||||
|
/**
|
||||||
|
* terminal_id → collaborator name; empty when none are configured. A supplier for the same
|
||||||
|
* reason as {@link #leadTerminals}: a collaborator tab recognised after construction (the tab
|
||||||
|
* scan discovering a newly-labelled tab) takes effect without a restart.
|
||||||
|
*/
|
||||||
|
private final Supplier<Map<String, String>> collaboratorTerminals;
|
||||||
|
|
||||||
/** Loopback-trust resolver: no token required, historical behaviour. Test-only. */
|
/** Loopback-trust resolver: no token required, historical behaviour. Test-only. */
|
||||||
CallerResolver(ConnectionIdentity identity) {
|
CallerResolver(ConnectionIdentity identity) {
|
||||||
@@ -124,17 +148,42 @@ public final class CallerResolver {
|
|||||||
/**
|
/**
|
||||||
* Live registry form that can confirm a bound slot is an architect slot.
|
* Live registry form that can confirm a bound slot is an architect slot.
|
||||||
*
|
*
|
||||||
* <p>This is the only public construction path. It keeps terminal bindings and slot roles in
|
* <p>It keeps terminal bindings and slot roles in the same {@link MemberRegistry}, so a
|
||||||
* the same {@link MemberRegistry}, so a configured architect can resolve as an architect.
|
* configured architect can resolve as an architect. No spawned-member roster or collaborator
|
||||||
|
* registry is consulted — equivalent to {@link #withLeadsAndMembers(ConnectionIdentity,
|
||||||
|
* boolean, String, Supplier, MemberRegistry, Function, Supplier)} with both absent. Kept for
|
||||||
|
* every caller that has neither to offer, so adding them did not churn every construction site.
|
||||||
*/
|
*/
|
||||||
public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity,
|
public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity,
|
||||||
boolean tokenMode, String token,
|
boolean tokenMode, String token,
|
||||||
Supplier<Map<String, String>> leadTerminals,
|
Supplier<Map<String, String>> leadTerminals,
|
||||||
MemberRegistry members) {
|
MemberRegistry members) {
|
||||||
|
return withLeadsAndMembers(identity, tokenMode, token, leadTerminals, members, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live registry form that also resolves a live spawned member to its own role, and a
|
||||||
|
* configured collaborator tab to {@link Role#COLLABORATOR}.
|
||||||
|
*
|
||||||
|
* <p>This is the only public construction path that exercises the full resolution order.
|
||||||
|
*
|
||||||
|
* @param spawnedMemberRole terminal_id → the role of the live spawned member occupying
|
||||||
|
* it, or {@code null} for a terminal no spawned member occupies.
|
||||||
|
* {@code null} here means no roster is consulted at all (every
|
||||||
|
* terminal falls through to the tab maps), not that none matches.
|
||||||
|
* @param collaboratorTerminals terminal_id → collaborator name, live like {@code leadTerminals}
|
||||||
|
*/
|
||||||
|
public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity,
|
||||||
|
boolean tokenMode, String token,
|
||||||
|
Supplier<Map<String, String>> leadTerminals,
|
||||||
|
MemberRegistry members,
|
||||||
|
Function<String, MemberRole> spawnedMemberRole,
|
||||||
|
Supplier<Map<String, String>> collaboratorTerminals) {
|
||||||
return new CallerResolver(identity, tokenMode, token, leadTerminals,
|
return new CallerResolver(identity, tokenMode, token, leadTerminals,
|
||||||
members == null ? null : members::snapshot,
|
members == null ? null : members::snapshot,
|
||||||
members == null ? null : members::roleForSlot,
|
members == null ? null : members::roleForSlot,
|
||||||
members == null ? null : members::nameForSlot);
|
members == null ? null : members::nameForSlot,
|
||||||
|
spawnedMemberRole, collaboratorTerminals);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Supplier<Map<String, String>> fixed(Map<String, String> leadTerminals) {
|
private static Supplier<Map<String, String>> fixed(Map<String, String> leadTerminals) {
|
||||||
@@ -160,6 +209,17 @@ public final class CallerResolver {
|
|||||||
Supplier<Map<String, String>> architectTerminals,
|
Supplier<Map<String, String>> architectTerminals,
|
||||||
Function<String, MemberRole> memberSlotRoles,
|
Function<String, MemberRole> memberSlotRoles,
|
||||||
Function<String, String> memberSlotNames) {
|
Function<String, String> memberSlotNames) {
|
||||||
|
this(identity, tokenMode, token, leadTerminals, architectTerminals, memberSlotRoles,
|
||||||
|
memberSlotNames, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token,
|
||||||
|
Supplier<Map<String, String>> leadTerminals,
|
||||||
|
Supplier<Map<String, String>> architectTerminals,
|
||||||
|
Function<String, MemberRole> memberSlotRoles,
|
||||||
|
Function<String, String> memberSlotNames,
|
||||||
|
Function<String, MemberRole> spawnedMemberRole,
|
||||||
|
Supplier<Map<String, String>> collaboratorTerminals) {
|
||||||
if (tokenMode && (token == null || token.isBlank())) {
|
if (tokenMode && (token == null || token.isBlank())) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"auth.mode=token requires a non-empty token; check that the env var named by "
|
"auth.mode=token requires a non-empty token; check that the env var named by "
|
||||||
@@ -172,6 +232,8 @@ public final class CallerResolver {
|
|||||||
this.architectTerminals = architectTerminals == null ? Map::of : architectTerminals;
|
this.architectTerminals = architectTerminals == null ? Map::of : architectTerminals;
|
||||||
this.memberSlotRoles = memberSlotRoles == null ? _ -> null : memberSlotRoles;
|
this.memberSlotRoles = memberSlotRoles == null ? _ -> null : memberSlotRoles;
|
||||||
this.memberSlotNames = memberSlotNames == null ? Function.identity() : memberSlotNames;
|
this.memberSlotNames = memberSlotNames == null ? Function.identity() : memberSlotNames;
|
||||||
|
this.spawnedMemberRole = spawnedMemberRole == null ? _ -> null : spawnedMemberRole;
|
||||||
|
this.collaboratorTerminals = collaboratorTerminals == null ? Map::of : collaboratorTerminals;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,14 +250,54 @@ public final class CallerResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The currently-recognised architect slots, {@code terminal_id → slot name} (CB-548).
|
* The currently-recognised collaborator tabs, {@code terminal_id → name}.
|
||||||
*
|
*
|
||||||
* <p>Read from the same supplier {@link #resolve} consults, so a slot that is <em>listed</em>
|
* <p>Read from the same supplier {@link #resolve} consults, for the reason given in
|
||||||
* here but would not <em>resolve</em> (or the reverse) cannot drift apart. Live for the same
|
* {@link #leads()}. Live for the same reason as {@link #leads()}.
|
||||||
* reason as {@link #leads()}.
|
|
||||||
*/
|
*/
|
||||||
public Map<String, String> members() {
|
public Map<String, String> collaborators() {
|
||||||
return architectTerminals.get();
|
return collaboratorTerminals.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether {@code target} names a terminal this resolver would resolve as a lead or a
|
||||||
|
* collaborator — the classifier a collaborator's {@code SEND} is checked against, read from the
|
||||||
|
* exact maps {@link #resolve} consults so a target that would resolve as a lead or collaborator
|
||||||
|
* is never the one a collaborator is refused to reach, or the reverse.
|
||||||
|
*/
|
||||||
|
public Predicate<String> knownLeadOrCollaborator() {
|
||||||
|
return target -> leadTerminals.get().containsKey(target)
|
||||||
|
|| 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,6 +310,25 @@ public final class CallerResolver {
|
|||||||
public Principal resolve(String remoteAddr, int remotePort, String authorizationHeader) {
|
public Principal resolve(String remoteAddr, int remotePort, String authorizationHeader) {
|
||||||
ConnectionIdentity.Caller c = identity.resolve(remoteAddr, remotePort);
|
ConnectionIdentity.Caller c = identity.resolve(remoteAddr, remotePort);
|
||||||
if (c.terminal() != null) {
|
if (c.terminal() != null) {
|
||||||
|
MemberRole spawnedRole = spawnedMemberRole.apply(c.terminal());
|
||||||
|
if (spawnedRole != null) {
|
||||||
|
// A live spawned member occupies this pane. Its identity is its own, whatever a tab
|
||||||
|
// map says about the same terminal — checked before every tab map, consulting none
|
||||||
|
// of them, so a tab label can never override a roster entry for the same terminal.
|
||||||
|
if (spawnedRole == MemberRole.ARCHITECT) {
|
||||||
|
// The roster only answers THAT this pane is a live spawned member; config still
|
||||||
|
// decides WHAT that member's slot grants (fleetd #424). A slot revoked after the
|
||||||
|
// bind must still demote this session on its very next request, so the roster's
|
||||||
|
// own ARCHITECT role is confirmed against the live slot role, exactly as the
|
||||||
|
// architect-slot step below confirms a binding with no live member session.
|
||||||
|
String slot = architectTerminals.get().get(c.terminal());
|
||||||
|
if (slot != null && memberSlotRoles.apply(slot) == MemberRole.ARCHITECT) {
|
||||||
|
return Principal.architect(memberSlotNames.apply(slot), c.terminal(), c.pid());
|
||||||
|
}
|
||||||
|
return Principal.worker(c.terminal(), c.pid());
|
||||||
|
}
|
||||||
|
return Principal.worker(c.terminal(), c.pid());
|
||||||
|
}
|
||||||
String lead = leadTerminals.get().get(c.terminal());
|
String lead = leadTerminals.get().get(c.terminal());
|
||||||
if (lead != null) {
|
if (lead != null) {
|
||||||
// The config names this pane as a lead's own. The pane mapping is exactly as
|
// The config names this pane as a lead's own. The pane mapping is exactly as
|
||||||
@@ -221,11 +342,18 @@ public final class CallerResolver {
|
|||||||
// The config/live binding names this pane as an architect slot's own. Same
|
// The config/live binding names this pane as an architect slot's own. Same
|
||||||
// unforgeable pane mapping; the live binding, never a request argument, decides.
|
// unforgeable pane mapping; the live binding, never a request argument, decides.
|
||||||
// Check the slot role too: this defence in depth prevents a bad lifecycle bind from
|
// Check the slot role too: this defence in depth prevents a bad lifecycle bind from
|
||||||
// escalating a dev, hunter or reviewer into an architect. Checked before
|
// escalating a dev, hunter or reviewer into an architect. This is the case the
|
||||||
// the worker fallback.
|
// spawned-member step above does not catch: a binding with no live member session.
|
||||||
return Principal.architect(memberSlotNames.apply(slot), c.terminal(), c.pid());
|
return Principal.architect(memberSlotNames.apply(slot), c.terminal(), c.pid());
|
||||||
}
|
}
|
||||||
return Principal.worker(c.terminal(), c.pid()); // unforgeable; never token-gated
|
String collaborator = collaboratorTerminals.get().get(c.terminal());
|
||||||
|
if (collaborator != null) {
|
||||||
|
// An operator-labelled collaborator tab, confirmed live by the same scan that
|
||||||
|
// confirms a lead tab. Checked last among the tab maps so a pane also matching one
|
||||||
|
// of the above keeps that stronger role.
|
||||||
|
return Principal.collaborator(collaborator, c.terminal(), c.pid());
|
||||||
|
}
|
||||||
|
return Principal.observer(c.terminal(), c.pid()); // unforgeable; never token-gated
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenMode) {
|
if (tokenMode) {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ package dev.ltms.fleet.auth;
|
|||||||
* @param pid the connecting process id, or {@code -1} when not resolvable (audit context)
|
* @param pid the connecting process id, or {@code -1} when not resolvable (audit context)
|
||||||
* @param name for a lead resolved from the CB-530 {@code leaders:} registry, which lead it is;
|
* @param name for a lead resolved from the CB-530 {@code leaders:} registry, which lead it is;
|
||||||
* for an architect resolved from the CB-548 {@code architects:} registry, which
|
* for an architect resolved from the CB-548 {@code architects:} registry, which
|
||||||
* slot it occupies; {@code null} for every other caller, including an unnamed primary
|
* slot it occupies; for a collaborator resolved from the {@code collaborators:}
|
||||||
|
* registry, which collaborator it is; {@code null} for every other caller,
|
||||||
|
* including an unnamed primary
|
||||||
*/
|
*/
|
||||||
public record Principal(Role role, String terminal, long pid, String name) {
|
public record Principal(Role role, String terminal, long pid, String name) {
|
||||||
|
|
||||||
@@ -73,6 +75,26 @@ public record Principal(Role role, String terminal, long pid, String name) {
|
|||||||
return new Principal(Role.ARCHITECT, terminal, pid, slotName);
|
return new Principal(Role.ARCHITECT, terminal, pid, slotName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaborator: a human-opened tab recognised by its exact label in the
|
||||||
|
* {@code collaborators:} registry.
|
||||||
|
*
|
||||||
|
* <p>Carries {@link Role#COLLABORATOR}. {@code name} is reporting only — it lets
|
||||||
|
* {@code fleet_whoami} say which collaborator is asking. Identity is the {@code terminal}:
|
||||||
|
* like a worker's it comes from the connection, so {@code ownsSession} works exactly as it
|
||||||
|
* does for a worker — a collaborator acts as its own pane and no other.
|
||||||
|
*/
|
||||||
|
public static Principal collaborator(String name, String terminal, long pid) {
|
||||||
|
return new Principal(Role.COLLABORATOR, terminal, pid, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unconfigured-pane floor: a loopback caller whose pane matched no other role.
|
||||||
|
*/
|
||||||
|
public static Principal observer(String terminal, long pid) {
|
||||||
|
return new Principal(Role.OBSERVER, terminal, pid);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isPrimary() {
|
public boolean isPrimary() {
|
||||||
return role == Role.PRIMARY;
|
return role == Role.PRIMARY;
|
||||||
}
|
}
|
||||||
@@ -81,15 +103,23 @@ public record Principal(Role role, String terminal, long pid, String name) {
|
|||||||
return role == Role.ARCHITECT;
|
return role == Role.ARCHITECT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isCollaborator() {
|
||||||
|
return role == Role.COLLABORATOR;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isWorker() {
|
public boolean isWorker() {
|
||||||
return role == Role.WORKER;
|
return role == Role.WORKER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isObserver() {
|
||||||
|
return role == Role.OBSERVER;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this caller is a spawned member with its own pane.
|
* Whether this caller is a spawned member with its own pane.
|
||||||
*
|
*
|
||||||
* <p>Both workers and architects are spawned members. A lead is excluded because recording it
|
* <p>Both workers and architects are spawned members. A lead is not: it is a peer the
|
||||||
* as present would count it as an available member in the roster.
|
* operator started and named, never a pane this daemon spawned.
|
||||||
*/
|
*/
|
||||||
public boolean isSpawnedMember() {
|
public boolean isSpawnedMember() {
|
||||||
return role == Role.WORKER || role == Role.ARCHITECT;
|
return role == Role.WORKER || role == Role.ARCHITECT;
|
||||||
@@ -115,11 +145,33 @@ public record Principal(Role role, String terminal, long pid, String name) {
|
|||||||
return terminal != null && terminal.equals(sessionId);
|
return terminal != null && terminal.equals(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable identity used to own tickets and open turns. The unnamed primary has no owner key —
|
||||||
|
* {@code null} — and that is matched against a ticket's recorded owner the same way any other
|
||||||
|
* key is: it owns a ticket another unnamed primary created, and nothing else.
|
||||||
|
*/
|
||||||
|
public String ownerKey() {
|
||||||
|
return switch (role) {
|
||||||
|
case PRIMARY -> name == null ? null : prefixed("leader", name);
|
||||||
|
case WORKER -> prefixed("worker", terminal);
|
||||||
|
case ARCHITECT -> prefixed("architect", terminal);
|
||||||
|
case COLLABORATOR -> prefixed("collaborator", name);
|
||||||
|
case OBSERVER -> prefixed("observer", terminal);
|
||||||
|
case ANONYMOUS -> "anonymous";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String prefixed(String role, String identity) {
|
||||||
|
return role + ":" + identity;
|
||||||
|
}
|
||||||
|
|
||||||
/** Short, non-sensitive description for audit lines and error details. */
|
/** Short, non-sensitive description for audit lines and error details. */
|
||||||
public String describe() {
|
public String describe() {
|
||||||
return switch (role) {
|
return switch (role) {
|
||||||
case WORKER -> "worker:" + terminal;
|
case WORKER -> "worker:" + terminal;
|
||||||
case ARCHITECT -> "architect:" + name;
|
case ARCHITECT -> "architect:" + name;
|
||||||
|
case COLLABORATOR -> "collaborator:" + name;
|
||||||
|
case OBSERVER -> "observer:" + terminal;
|
||||||
case PRIMARY -> name == null ? "primary" : "leader:" + name;
|
case PRIMARY -> name == null ? "primary" : "leader:" + name;
|
||||||
case ANONYMOUS -> "anonymous";
|
case ANONYMOUS -> "anonymous";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ package dev.ltms.fleet.auth;
|
|||||||
public enum Role {
|
public enum Role {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The orchestrating session. Established either by being a loopback caller that is not a
|
* The orchestrating session. Established either by being a loopback caller that resolves to
|
||||||
* worker pane (under {@code loopback-trust}) or by presenting a valid bearer token (under
|
* no herdr pane at all (under {@code loopback-trust}) or by presenting a valid bearer token
|
||||||
* {@code token} mode).
|
* (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,
|
PRIMARY,
|
||||||
|
|
||||||
@@ -34,6 +35,29 @@ public enum Role {
|
|||||||
*/
|
*/
|
||||||
ARCHITECT,
|
ARCHITECT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A config-declared, human-opened tab recognised by its exact label (the {@code
|
||||||
|
* fleet.collaborators.<name>.tab} registry). Never spawned — identity comes from the
|
||||||
|
* connection, never a request argument, exactly like {@link #WORKER} and {@link #ARCHITECT}.
|
||||||
|
* May {@code SEND} only to a configured lead or collaborator, {@code REPLY}/{@code ASK} only
|
||||||
|
* as its own pane, and {@code READ}/{@code METRICS}; 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}).
|
||||||
|
*/
|
||||||
|
COLLABORATOR,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}, {@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,
|
||||||
|
|
||||||
/** Authenticated as nothing. Authorized for nothing but {@code /healthz}. */
|
/** Authenticated as nothing. Authorized for nothing but {@code /healthz}. */
|
||||||
ANONYMOUS
|
ANONYMOUS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import java.util.function.Supplier;
|
|||||||
* {@code models:} above: {@code dev.ltms.fleet.lead.LeadRollover} holds a
|
* {@code models:} above: {@code dev.ltms.fleet.lead.LeadRollover} holds a
|
||||||
* {@code Supplier<FleetConfig.LeadRollover>} (the same {@code () -> config.get().x()} shape)
|
* {@code Supplier<FleetConfig.LeadRollover>} (the same {@code () -> config.get().x()} shape)
|
||||||
* and reads {@code handoverPath}/{@code requireOperatorConfirm}/{@code maxDocAgeSeconds}/
|
* and reads {@code handoverPath}/{@code requireOperatorConfirm}/{@code maxDocAgeSeconds}/
|
||||||
* {@code turnSettleSeconds}/{@code clearSettleSeconds}/{@code bootstrapText} fresh on every
|
* {@code turnSettleSeconds}/{@code relaunchReadySeconds}/{@code bootstrapText} fresh on every
|
||||||
* {@code open()}/{@code confirm()} call (and on the deferred post-{@code confirm()}
|
* {@code open()}/{@code confirm()} call (and on the deferred post-{@code confirm()}
|
||||||
* continuation fleetd #480's correction added — see {@code LeadRollover}'s class doc) rather
|
* continuation fleetd #480's correction added — see {@code LeadRollover}'s class doc) rather
|
||||||
* than capturing them into fields at construction — unlike its closest
|
* than capturing them into fields at construction — unlike its closest
|
||||||
@@ -615,7 +615,7 @@ public final class ConfigRef implements Supplier<FleetConfig> {
|
|||||||
// may bind to, AND what a slot already bound still grants) through its own instance of that
|
// may bind to, AND what a slot already bound still grants) through its own instance of that
|
||||||
// same supplier shape — see MemberRegistry.live and its class doc for the binding rule:
|
// same supplier shape — see MemberRegistry.live and its class doc for the binding rule:
|
||||||
// removing a slot revokes ARCHITECT on the bound pane's very next request, and only the slot
|
// removing a slot revokes ARCHITECT on the bound pane's very next request, and only the slot
|
||||||
// OCCUPANCY survives, so the demoted session keeps its slot key until it unbinds. Only
|
// OCCUPANCY survives, so the demoted session keeps its slot key until it unbinds.
|
||||||
// fleet.leaders is frozen (Fleetd.java:281 reads cfg.fleet().leaders() off the startup
|
// fleet.leaders is frozen (Fleetd.java:281 reads cfg.fleet().leaders() off the startup
|
||||||
// snapshot to build both the LeadTabScanner's tab-label-to-name map, wired into
|
// snapshot to build both the LeadTabScanner's tab-label-to-name map, wired into
|
||||||
// CallerResolver.withLeadsAndMembers at Fleetd.java:620/624, and — when herdr answered —
|
// CallerResolver.withLeadsAndMembers at Fleetd.java:620/624, and — when herdr answered —
|
||||||
@@ -635,6 +635,11 @@ public final class ConfigRef implements Supplier<FleetConfig> {
|
|||||||
+ "live through that same supplier for placement AND through a separate supplier "
|
+ "live through that same supplier for placement AND through a separate supplier "
|
||||||
+ "on MemberRegistry for spawn-time identity — both already applied");
|
+ "on MemberRegistry for spawn-time identity — both already applied");
|
||||||
}
|
}
|
||||||
|
if (!Objects.equals(collaboratorsOf(old), collaboratorsOf(fresh))) {
|
||||||
|
changed.add("fleet: fleet.collaborators (each collaborator's tab) is read once at "
|
||||||
|
+ "startup to build the LeadTabScanner's identity map, which is not rebuilt on "
|
||||||
|
+ "reload, so a collaborator added, removed, or given a new tab: needs a restart");
|
||||||
|
}
|
||||||
// Kept in step with SPLIT_KEYS the same way changedColdKeys is kept in step with COLD_KEYS —
|
// Kept in step with SPLIT_KEYS the same way changedColdKeys is kept in step with COLD_KEYS —
|
||||||
// every message here must be traceable to one of the split keys the class doc documents.
|
// every message here must be traceable to one of the split keys the class doc documents.
|
||||||
// NOTE what this does NOT prove, per the javadoc above: it does not catch a SPLIT_KEYS
|
// NOTE what this does NOT prove, per the javadoc above: it does not catch a SPLIT_KEYS
|
||||||
@@ -650,6 +655,11 @@ public final class ConfigRef implements Supplier<FleetConfig> {
|
|||||||
return cfg.fleet() == null ? Map.of() : cfg.fleet().leaders();
|
return cfg.fleet() == null ? Map.of() : cfg.fleet().leaders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@code cfg.fleet().collaborators()}, defensively, in case a caller hands in a non-defaulted config. */
|
||||||
|
private static Map<String, FleetConfig.Collaborator> collaboratorsOf(FleetConfig cfg) {
|
||||||
|
return cfg.fleet() == null ? Map.of() : cfg.fleet().collaborators();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link FleetConfig.Profile} record components deliberately left out of
|
* {@link FleetConfig.Profile} record components deliberately left out of
|
||||||
* {@link #sameLaunchSettings} because they are read <em>live</em>, not baked in at spawn — see
|
* {@link #sameLaunchSettings} because they are read <em>live</em>, not baked in at spawn — see
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import java.util.Comparator;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
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
|
* One entry of the {@code leaders:} registry — a pane that orchestrates rather than one that is
|
||||||
* that is orchestrated.
|
* orchestrated.
|
||||||
*
|
*
|
||||||
* <p>Why a registry and not a second {@code primary:}: {@code primary.terminal} is singular by
|
* <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
|
* construction, so a session in any other pane resolves as a worker. That is correct while one
|
||||||
@@ -1103,49 +1104,49 @@ public record FleetConfig(
|
|||||||
* <p>{@code kind} and {@code model} are descriptive only: they document what runs in the pane
|
* <p>{@code kind} and {@code model} are descriptive only: they document what runs in the pane
|
||||||
* and are reported back by {@code fleet_whoami}.
|
* 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
|
* <p>A lead with {@code profile} and {@code instances} set may be launched by the daemon when
|
||||||
* pre-existed, which is why it had to be recognised by configuration rather than created. With
|
* none is live; recognition always comes first, so only the shortfall is launched.
|
||||||
* {@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><b>{@code tab} replaced {@code terminal} (CB-579).</b> A herdr {@code terminal_id} changes
|
* <p>Every lead's tab is labelled {@link #LEAD_TAB_LABEL}, a fixed constant — not a per-entry
|
||||||
* every time the lead's session restarts, so pinning one cost a config edit and a daemon restart
|
* config value. {@code workspace} is therefore what tells one lead from another: two leaders
|
||||||
* per restart. A tab is stable: a human opens it once, it holds exactly one pane, and its label
|
* sharing one space would both resolve to the one tab named {@code lead} there, so only one
|
||||||
* survives restarts of the agent inside it — so identity is now the tab label alone.
|
* 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
|
* @param profile the {@code profiles:} entry to launch this lead on when one must
|
||||||
* be created; {@code null} ⇒ recognise-only, never create.
|
* be created; {@code null} ⇒ recognise-only, never create.
|
||||||
* <p>fleetd #176: also the field {@code Fleetd.leadSeatLookup} reads
|
* <p>Also the field {@code Fleetd.leadSeatLookup} reads to learn
|
||||||
* to learn which account this lead's own live session shares — set it
|
* which account this lead's own live session shares — set it
|
||||||
* (safely, even on an already-running recognise-only lead: naming a
|
* (safely, even on an already-running recognise-only lead: naming a
|
||||||
* profile here never starts anything beyond {@code instances}) so a
|
* profile here never starts anything beyond {@code instances}) so a
|
||||||
* {@code subscription: true} worker profile sharing its
|
* {@code subscription: true} worker profile sharing its
|
||||||
* {@code effectiveCredentialId()} has this lead's seat subtracted from
|
* {@code effectiveCredentialId()} has this lead's seat subtracted from
|
||||||
* {@code fleet_list}'s {@code free}. {@code null} here also means this
|
* {@code fleet_list}'s {@code free}. {@code null} here also means this
|
||||||
* lead's seat cannot be derived and is not counted.
|
* lead's seat cannot be derived and is not counted.
|
||||||
* @param tab the exact tab label hosting this lead, matched case-insensitively;
|
* @param tab deprecated legacy tab label, matched case-insensitively within
|
||||||
* the only field identity depends on. Required — a lead with no
|
* this lead's own space alongside {@link #LEAD_TAB_LABEL}. Optional —
|
||||||
* {@code tab} can never be discovered, launched or not
|
* {@code null}/blank means only the constant is accepted
|
||||||
* @param instances how many of this lead should be live (default 1). The daemon
|
* @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
|
* launches only the shortfall, so a restart adopts rather than doubles
|
||||||
* @param tabPrefix no longer used to find a lead's tab — {@code tab} is matched
|
* @param tabPrefix lead-tab naming convention checked against member labels. Lead
|
||||||
* exactly. Its only remaining job is the startup collision guard
|
* identity uses {@link #acceptedLabels()}. Default {@code "lead:"}
|
||||||
* ({@link #validateLeadTabPrefixes()}), which still uses it to refuse
|
|
||||||
* a worker {@code tabLabel} template that could be misread as a lead.
|
|
||||||
* Default {@code "lead:"}
|
|
||||||
* @param scanIntervalSeconds how long a tab scan is cached before herdr is asked again; also the
|
* @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
|
* worst case before a newly-labelled tab is recognised. Default 10
|
||||||
* @param kind which agent runs there ({@code claude}, {@code opencode}, …)
|
* @param kind which agent runs there ({@code claude}, {@code opencode}, …)
|
||||||
* @param model the model or selector it runs, for operators reading the roster
|
* @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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record Leader(String profile, String tab, Integer instances, String tabPrefix,
|
public record Leader(String profile, String tab, Integer instances, String tabPrefix,
|
||||||
Integer scanIntervalSeconds, String kind, String model,
|
Integer scanIntervalSeconds, String kind, String model,
|
||||||
String workspace, String cwd) {
|
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.
|
* {@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
|
* 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).
|
* tab label, so a lead sharing the members' space is still discovered (see LeadLauncher).
|
||||||
@@ -1173,9 +1174,42 @@ public record FleetConfig(
|
|||||||
return profile != null && !profile.isBlank() && instances > 0;
|
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() {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab fleetd recognises as a collaborator, keyed by name (fleetd #669).
|
||||||
|
*
|
||||||
|
* <p>Recognise-only: there is no {@code profile}, no {@code instances} and no {@code kind}.
|
||||||
|
* Nothing here ever launches a pane.
|
||||||
|
*
|
||||||
|
* <p>{@code tabPrefix} is absent. Identity is matched on the exact {@code tab} alone.
|
||||||
|
*
|
||||||
|
* @param tab the exact tab label hosting this collaborator, matched case-insensitively; the
|
||||||
|
* only field identity depends on. Required — an entry with no {@code tab} can
|
||||||
|
* never be discovered.
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Collaborator(String tab) {
|
||||||
|
public Collaborator {
|
||||||
|
tab = (tab == null || tab.isBlank()) ? null : tab.strip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,15 +1250,18 @@ public record FleetConfig(
|
|||||||
* is exactly compatible with that. The pool is also what replaced {@code defaultProfile:} — an
|
* is exactly compatible with that. The pool is also what replaced {@code defaultProfile:} — an
|
||||||
* unqualified spawn names a role, and the role's pool supplies the candidates.
|
* unqualified spawn names a role, and the role's pool supplies the candidates.
|
||||||
*
|
*
|
||||||
* @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
|
* @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
|
||||||
* @param architects profiles the {@code architect} role may run on
|
* @param architects profiles the {@code architect} role may run on
|
||||||
* @param developers profiles the {@code dev} role may run on
|
* @param developers profiles the {@code dev} role may run on
|
||||||
* @param hunters profiles the {@code hunter} role may run on
|
* @param hunters profiles the {@code hunter} role may run on
|
||||||
* @param reviewers profiles the {@code reviewer} role may run on
|
* @param reviewers profiles the {@code reviewer} role may run on
|
||||||
* @param charters optional launch-charter text keyed by singular role wire name
|
* @param charters optional launch-charter text keyed by singular role wire name
|
||||||
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
|
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
|
||||||
* {@code {model}} and {@code {n}} (a per role+profile counter) are
|
* {@code {model}} and {@code {n}} (a per role+profile counter) are
|
||||||
* substituted. Default {@link #DEFAULT_TAB_LABEL}
|
* substituted. Default {@link #DEFAULT_TAB_LABEL}
|
||||||
|
* @param collaborators tabs fleetd recognises as collaborators (fleetd #669), keyed by name.
|
||||||
|
* Recognise-only, exactly like a {@code profile}-less {@link Leader}:
|
||||||
|
* nothing here is ever auto-launched.
|
||||||
*/
|
*/
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record Fleet(Map<String, Leader> leaders,
|
public record Fleet(Map<String, Leader> leaders,
|
||||||
@@ -1233,13 +1270,11 @@ public record FleetConfig(
|
|||||||
Map<String, Slot> hunters,
|
Map<String, Slot> hunters,
|
||||||
Map<String, Slot> reviewers,
|
Map<String, Slot> reviewers,
|
||||||
Map<String, String> charters,
|
Map<String, String> charters,
|
||||||
String tabLabel) {
|
String tabLabel,
|
||||||
|
Map<String, Collaborator> collaborators) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role first, so the tab bar reads as the fleet and so the label shares a namespace with a
|
* Role first, so the tab bar identifies the member's fleet role.
|
||||||
* lead's {@code tabPrefix}. Because {@code {role}} comes from a closed enum, a generated
|
|
||||||
* member label can never begin with {@code "lead:"} — the clash that
|
|
||||||
* {@link #validateLeadTabPrefixes()} used to have to check for is unrepresentable here.
|
|
||||||
*/
|
*/
|
||||||
public static final String DEFAULT_TAB_LABEL = "{role}: {profile} #{n}";
|
public static final String DEFAULT_TAB_LABEL = "{role}: {profile} #{n}";
|
||||||
|
|
||||||
@@ -1251,26 +1286,30 @@ public record FleetConfig(
|
|||||||
reviewers = unmodifiableOrEmpty(reviewers);
|
reviewers = unmodifiableOrEmpty(reviewers);
|
||||||
charters = unmodifiableOrEmpty(charters);
|
charters = unmodifiableOrEmpty(charters);
|
||||||
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
|
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
|
||||||
|
collaborators = unmodifiableOrEmpty(collaborators);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fleet with no configured launch charters — the shape every deployment had before
|
* A fleet with no configured launch charters and no collaborators — the shape every
|
||||||
* CB-566, and what most tests want.
|
* deployment had before CB-566, and what most tests want.
|
||||||
*
|
*
|
||||||
* <p>Kept deliberately, even though an overload that drops a new field is normally the
|
* <p>Kept deliberately, even though an overload that drops a new field is normally the
|
||||||
* shape to avoid. It is safe here because nothing <em>reads</em> a charter through a
|
* shape to avoid. It is safe here because nothing <em>reads</em> a charter through a
|
||||||
* constructor: the launcher reads {@code fleet.charters()} from the live config. Jackson
|
* constructor: the launcher reads {@code fleet.charters()} from the live config. Jackson
|
||||||
* binds the canonical constructor, so this one cannot swallow an operator's YAML.
|
* binds the canonical constructor, so this one cannot swallow an operator's YAML.
|
||||||
|
* {@code collaborators} is dropped the same way and for the same reason: no caller of
|
||||||
|
* this overload has ever needed to set it, so it defaults to empty here exactly as the
|
||||||
|
* canonical constructor would default an absent YAML key.
|
||||||
*/
|
*/
|
||||||
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
||||||
Map<String, Slot> developers, Map<String, Slot> reviewers,
|
Map<String, Slot> developers, Map<String, Slot> reviewers,
|
||||||
Map<String, String> charters, String tabLabel) {
|
Map<String, String> charters, String tabLabel) {
|
||||||
this(leaders, architects, developers, null, reviewers, charters, tabLabel);
|
this(leaders, architects, developers, null, reviewers, charters, tabLabel, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
||||||
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
|
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
|
||||||
this(leaders, architects, developers, null, reviewers, null, tabLabel);
|
this(leaders, architects, developers, null, reviewers, null, tabLabel, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1395,15 +1434,16 @@ public record FleetConfig(
|
|||||||
* before anything exists to call — the same fact already true of adding a brand-new
|
* before anything exists to call — the same fact already true of adding a brand-new
|
||||||
* {@code profiles:} entry.
|
* {@code profiles:} entry.
|
||||||
*
|
*
|
||||||
* <p><strong>{@code turnSettleSeconds} (fleetd #480 correction):</strong> {@code confirm()} is
|
* <p><strong>{@code turnSettleSeconds}:</strong> {@code confirm()} is called FROM the calling
|
||||||
* called FROM the calling lead's own turn, so its pane is still {@code WORKING} the instant
|
* lead's own turn, so its pane is still {@code WORKING} the instant {@code confirm()} validates
|
||||||
* {@code confirm()} validates every gate and schedules the roll. {@code
|
* every gate and schedules the roll. {@code dev.ltms.fleet.lead.LeadRollover}'s deferred
|
||||||
* dev.ltms.fleet.lead.LeadRollover}'s deferred continuation waits up to this many seconds for
|
* continuation waits up to this many seconds for that SAME pane to report {@code IDLE} or
|
||||||
* that SAME pane to report an injectable state again — i.e. for the calling turn to actually
|
* {@code DONE} — i.e. for the calling turn to actually end — before it ends the old pane's
|
||||||
* end — before it sends {@code /clear} at all. If that wait times out, no {@code /clear} is
|
* process at all. {@code BLOCKED} does not count: that is a live turn merely paused, not one
|
||||||
* ever sent: a lead that never goes idle is still doing real work, and clearing it would
|
* that has finished. If that wait times out, the old pane is never touched: a lead that never
|
||||||
* destroy live context. This is a separate wait from {@code clearSettleSeconds} below, which
|
* goes idle is still doing real work, and the roll ends that pane's whole process — there is no
|
||||||
* bounds the SECOND wait, for the pane to re-settle AFTER {@code /clear} has already gone out.
|
* way back from this once it runs, so this wait is the only thing standing between "still
|
||||||
|
* working" and "gone".
|
||||||
*
|
*
|
||||||
* @param handoverPath required when this block is present — where the handover file a fresh
|
* @param handoverPath required when this block is present — where the handover file a fresh
|
||||||
* lead session reads must live. There is no sane non-null default for an
|
* lead session reads must live. There is no sane non-null default for an
|
||||||
@@ -1422,37 +1462,52 @@ public record FleetConfig(
|
|||||||
* @param maxDocAgeSeconds default 3600 — refuse a handover file whose modified time is older
|
* @param maxDocAgeSeconds default 3600 — refuse a handover file whose modified time is older
|
||||||
* than this many seconds, so a stale leftover from an earlier rollover
|
* than this many seconds, so a stale leftover from an earlier rollover
|
||||||
* attempt can never be mistaken for a fresh one.
|
* attempt can never be mistaken for a fresh one.
|
||||||
* @param turnSettleSeconds default 20 — bound on how long the deferred roll waits for the
|
* @param turnSettleSeconds default 300 — bound on how long the deferred roll waits for the
|
||||||
* CALLING lead's own turn to end (its pane to report injectable again)
|
* CALLING lead's own turn to end (its pane to report {@code IDLE} or
|
||||||
* before sending {@code /clear} at all. See the paragraph above.
|
* {@code DONE}) before ending that pane's process at all. See the
|
||||||
* @param clearSettleSeconds default 20 — bound on how long to wait for the lead's pane to
|
* paragraph above.
|
||||||
* report an injectable state again after {@code /clear} before giving up. A
|
* @param relaunchReadySeconds default 45 — bound on EACH of three separate waits that run after
|
||||||
* roll that times out here never sends {@code bootstrapText}.
|
* the old lead's pane has been torn down and a fresh one launched: first,
|
||||||
|
* for the fresh pane itself to reach a real turn boundary ({@code IDLE} or
|
||||||
|
* {@code DONE}, never merely {@code BLOCKED}) — the safety gate, since
|
||||||
|
* typing into a pane that has not finished booting loses the keystrokes;
|
||||||
|
* second, for the fresh terminal to show up as a recognised lead, which is
|
||||||
|
* bookkeeping rather than a safety gate, so a timeout on this second wait
|
||||||
|
* does not withhold {@code bootstrapText} — it is sent once the pane is
|
||||||
|
* ready regardless. Recognition comes from the same periodically-refreshed
|
||||||
|
* scan {@code LeadTabScanner} already keeps ({@code scanIntervalSeconds},
|
||||||
|
* 10s live), so a budget has to clear more than one scan interval to leave
|
||||||
|
* any real margin for the CLI's own boot time; 20 was rejected for exactly
|
||||||
|
* that reason — at a 10s scan interval it only buys two scans. 45 buys
|
||||||
|
* roughly four; third, to retry sending {@code bootstrapText} while herdr
|
||||||
|
* reports {@code agent_not_ready}. A timeout on the first wait or the third
|
||||||
|
* one withholds {@code bootstrapText}.
|
||||||
* @param bootstrapText default a sentence naming the RESOLVED handover path — sent to the
|
* @param bootstrapText default a sentence naming the RESOLVED handover path — sent to the
|
||||||
* lead's pane once it settles after {@code /clear}, telling the fresh
|
* fresh lead's pane once it reaches a real turn boundary after relaunch,
|
||||||
* session where to read the handover and carry on. Left {@code null} here
|
* telling the fresh session where to read the handover and carry on. Left
|
||||||
* when the operator configures none: the default sentence cannot be built
|
* {@code null} here when the operator configures none: the default sentence
|
||||||
* at construction time because it must name the path AFTER {@code
|
* cannot be built at construction time because it must name the path AFTER
|
||||||
* dev.ltms.fleet.lead.LeadRollover#open} has resolved a relative {@code
|
* {@code dev.ltms.fleet.lead.LeadRollover#open} has resolved a relative
|
||||||
* handoverPath} against the calling lead's workspace, which this record has
|
* {@code handoverPath} against the calling lead's workspace, which this
|
||||||
* no way to know — see {@link #bootstrapTextFor(String)}.
|
* record has no way to know — see {@link #bootstrapTextFor(String)}.
|
||||||
*/
|
*/
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record LeadRollover(String handoverPath, Boolean requireOperatorConfirm,
|
public record LeadRollover(String handoverPath, Boolean requireOperatorConfirm,
|
||||||
Integer maxDocAgeSeconds, Integer turnSettleSeconds,
|
Integer maxDocAgeSeconds, Integer turnSettleSeconds,
|
||||||
Integer clearSettleSeconds, String bootstrapText) {
|
Integer relaunchReadySeconds, String bootstrapText) {
|
||||||
public LeadRollover {
|
public LeadRollover {
|
||||||
requireOperatorConfirm = requireOperatorConfirm == null || requireOperatorConfirm;
|
requireOperatorConfirm = requireOperatorConfirm == null || requireOperatorConfirm;
|
||||||
maxDocAgeSeconds = (maxDocAgeSeconds == null || maxDocAgeSeconds <= 0) ? 3600 : maxDocAgeSeconds;
|
maxDocAgeSeconds = (maxDocAgeSeconds == null || maxDocAgeSeconds <= 0) ? 3600 : maxDocAgeSeconds;
|
||||||
turnSettleSeconds = (turnSettleSeconds == null || turnSettleSeconds <= 0) ? 20 : turnSettleSeconds;
|
turnSettleSeconds = (turnSettleSeconds == null || turnSettleSeconds <= 0) ? 300 : turnSettleSeconds;
|
||||||
clearSettleSeconds = (clearSettleSeconds == null || clearSettleSeconds <= 0) ? 20 : clearSettleSeconds;
|
relaunchReadySeconds = (relaunchReadySeconds == null || relaunchReadySeconds <= 0)
|
||||||
|
? 45 : relaunchReadySeconds;
|
||||||
bootstrapText = (bootstrapText == null || bootstrapText.isBlank()) ? null : bootstrapText;
|
bootstrapText = (bootstrapText == null || bootstrapText.isBlank()) ? null : bootstrapText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The text actually sent to the lead's pane once it settles after {@code /clear}: the
|
* The text actually sent to the fresh lead's pane once it reaches a real turn boundary
|
||||||
* operator's configured {@link #bootstrapText} when one is set, otherwise the default
|
* after relaunch: the operator's configured {@link #bootstrapText} when one is set,
|
||||||
* sentence built from {@code resolvedHandoverPath}.
|
* otherwise the default sentence built from {@code resolvedHandoverPath}.
|
||||||
*
|
*
|
||||||
* @param resolvedHandoverPath the ABSOLUTE path {@code dev.ltms.fleet.lead.LeadRollover
|
* @param resolvedHandoverPath the ABSOLUTE path {@code dev.ltms.fleet.lead.LeadRollover
|
||||||
* #open} already resolved — never the raw configured {@link
|
* #open} already resolved — never the raw configured {@link
|
||||||
@@ -1895,6 +1950,7 @@ public record FleetConfig(
|
|||||||
rejectNegativeMaxLoad(yaml);
|
rejectNegativeMaxLoad(yaml);
|
||||||
rejectAutoCompactWindowOutOfRange(yaml);
|
rejectAutoCompactWindowOutOfRange(yaml);
|
||||||
warnConflictingAutoCompactWindows(yaml);
|
warnConflictingAutoCompactWindows(yaml);
|
||||||
|
warnRetiredClearSettleSecondsKey(yaml);
|
||||||
rejectMalformedProfilePatterns(yaml);
|
rejectMalformedProfilePatterns(yaml);
|
||||||
rejectUnknownKind(yaml);
|
rejectUnknownKind(yaml);
|
||||||
rejectUnknownAuthMode(yaml);
|
rejectUnknownAuthMode(yaml);
|
||||||
@@ -1913,7 +1969,7 @@ public record FleetConfig(
|
|||||||
|
|
||||||
/** The {@code fleet:} child blocks whose direct children are slot names. */
|
/** The {@code fleet:} child blocks whose direct children are slot names. */
|
||||||
private static final Set<String> FLEET_POOL_KEYS =
|
private static final Set<String> FLEET_POOL_KEYS =
|
||||||
Set.of("leaders", "architects", "developers", "hunters", "reviewers");
|
Set.of("leaders", "architects", "developers", "hunters", "reviewers", "collaborators");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a {@code fleet:} role pool whose slot names repeat (CB-548, re-homed by CB-557).
|
* Reject a {@code fleet:} role pool whose slot names repeat (CB-548, re-homed by CB-557).
|
||||||
@@ -1923,7 +1979,7 @@ public record FleetConfig(
|
|||||||
* daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by
|
* daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by
|
||||||
* default, so duplicates are caught here, at parse time, before the map is built.
|
* default, so duplicates are caught here, at parse time, before the map is built.
|
||||||
*
|
*
|
||||||
* <p>Only the five pools <em>directly under the top-level {@code fleet:}</em> are considered,
|
* <p>Only the six pools <em>directly under the top-level {@code fleet:}</em> are considered,
|
||||||
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
|
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
|
||||||
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
|
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
|
||||||
*
|
*
|
||||||
@@ -2058,13 +2114,6 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The top-level keys in {@code yaml} that this build does not understand, sorted. Package-private
|
|
||||||
* so the guardrail is asserted directly rather than through a log appender.
|
|
||||||
*
|
|
||||||
* @return empty when everything is known, or when {@code yaml} is not a mapping at all (a
|
|
||||||
* malformed file is {@code readValue}'s error to report, not this method's)
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Top-level keys renamed by the member taxonomy, mapped old → new.
|
* Top-level keys renamed by the member taxonomy, mapped old → new.
|
||||||
*
|
*
|
||||||
@@ -2318,6 +2367,33 @@ public record FleetConfig(
|
|||||||
names, String.join(", ", detail));
|
names, String.join(", ", detail));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warn when a {@code leadRollover:} block still sets the retired {@code clearSettleSeconds}
|
||||||
|
* key. {@link LeadRollover} carries {@code @JsonIgnoreProperties(ignoreUnknown = true)} and no
|
||||||
|
* longer declares that component, so Jackson drops it with no signal of its own — this raw-YAML
|
||||||
|
* check is the only place an operator's now-inert setting is reported at all; by the time a
|
||||||
|
* {@link LeadRollover} instance exists to run a validator against, the key is already gone.
|
||||||
|
*
|
||||||
|
* @param yaml the raw config text
|
||||||
|
*/
|
||||||
|
static void warnRetiredClearSettleSecondsKey(String yaml) {
|
||||||
|
Map<?, ?> raw;
|
||||||
|
try {
|
||||||
|
raw = YAML.readValue(yaml, Map.class);
|
||||||
|
} catch (IOException | IllegalArgumentException e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (raw == null || !(raw.get("leadRollover") instanceof Map<?, ?> leadRollover)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (leadRollover.containsKey("clearSettleSeconds")) {
|
||||||
|
log.warn("leadRollover.clearSettleSeconds is retired and no longer read. Set "
|
||||||
|
+ "leadRollover.relaunchReadySeconds instead: it bounds how long to wait, after "
|
||||||
|
+ "a lead is relaunched, for its pane to become ready and then for it to be "
|
||||||
|
+ "recognised as a lead. Remove clearSettleSeconds from fleetd.yaml.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a profile whose {@code errorPattern} (fleetd #201 Unit 5) or {@code exhaustedPattern}
|
* Reject a profile whose {@code errorPattern} (fleetd #201 Unit 5) or {@code exhaustedPattern}
|
||||||
* (CB-578 stage A) is not a valid Java regex, naming the profile, the key, and the parser's own
|
* (CB-578 stage A) is not a valid Java regex, naming the profile, the key, and the parser's own
|
||||||
@@ -2572,6 +2648,13 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-level keys in {@code yaml} that this build does not understand, sorted. Package-private
|
||||||
|
* so the guardrail is asserted directly rather than through a log appender.
|
||||||
|
*
|
||||||
|
* @return empty when everything is known, or when {@code yaml} is not a mapping at all (a
|
||||||
|
* malformed file is {@code readValue}'s error to report, not this method's)
|
||||||
|
*/
|
||||||
static List<String> unknownTopLevelKeys(String yaml) {
|
static List<String> unknownTopLevelKeys(String yaml) {
|
||||||
Map<?, ?> raw;
|
Map<?, ?> raw;
|
||||||
try {
|
try {
|
||||||
@@ -2682,83 +2765,224 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a lead-scan convention that a worker tab would also satisfy (CB-531).
|
* Reject a member tab-label template that could render as a configured lead or collaborator
|
||||||
|
* 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>The scan reads a tab label and concludes "a lead lives here". fleetd also <em>writes</em>
|
* <p>{@code fleet.collaborators} has no {@code tabPrefix}: identity is matched on the exact
|
||||||
* tab labels — every member gets one rendered into its tab. Choose a lead {@code tabPrefix} that
|
* {@code tab} alone, so only the exact-render check applies there, not the prefix check.
|
||||||
* a member template matches and the daemon starts labelling its own members as leads, promoting
|
|
||||||
* the entire fleet to {@link dev.ltms.fleet.auth.Role#PRIMARY} with no message and no diff.
|
|
||||||
* {@link #validatePanePlacementAgainstLeadTabs()} is the check that stops a pane-placed member
|
|
||||||
* from landing inside a lead's tab in the first place; this check is a second, independent
|
|
||||||
* guard that catches the hazard even when every profile places members correctly, by refusing
|
|
||||||
* a label that a scan would still misread as a lead.
|
|
||||||
*
|
*
|
||||||
* <p>CB-557 shrank this check rather than removing it. The default template is
|
* @throws IllegalStateException when the fleet template or a profile {@code tabLabel} override
|
||||||
* {@code "{role}: {profile} #{n}"} and {@code {role}} comes from a closed enum, so a
|
* can render as a configured lead or collaborator tab, as the
|
||||||
* <em>generated</em> label can no longer collide by construction. What remains checkable is what
|
* fixed lead tab label, or match a lead-tab prefix; when two
|
||||||
* an operator still writes by hand: the {@code fleet.tabLabel} template and any per-profile
|
* leaders share one space; when two collaborators (or a lead and
|
||||||
* {@code tabLabel} override.
|
* a collaborator) carry the same exact {@code tab}
|
||||||
*
|
* (case-insensitively); or when a collaborator's {@code tab}
|
||||||
* <p>Fatal rather than a warning, unlike {@link #warnUnknownTopLevelKeys}: an unknown key means
|
* equals the fixed lead tab label
|
||||||
* a feature does nothing, while this means a feature does the opposite of what it says.
|
|
||||||
*
|
|
||||||
* @throws IllegalStateException when the fleet template or any profile's {@code tabLabel}
|
|
||||||
* override starts with a configured lead prefix
|
|
||||||
*/
|
*/
|
||||||
public void validateLeadTabPrefixes() {
|
public void validateLeadTabPrefixes() {
|
||||||
if (fleet == null || fleet.leaders().isEmpty()) {
|
if (fleet == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<String> bad = new ArrayList<>();
|
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) -> {
|
fleet.leaders().forEach((leadName, leader) -> {
|
||||||
if (leader == null) {
|
if (leader == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String tab = leader.tab();
|
||||||
String prefix = leader.tabPrefix();
|
String prefix = leader.tabPrefix();
|
||||||
// The fleet-wide template is checked once per prefix: it labels every member that has no
|
if (templateCanRenderAs(fleet.tabLabel(), tab)) {
|
||||||
// override, so one bad template promotes the entire fleet, not one profile.
|
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as the tab of "
|
||||||
if (startsWithIgnoreCase(fleet.tabLabel(), prefix)) {
|
+ "lead '" + leadName + "' (\"" + tab + "\")");
|
||||||
|
} else if (startsWithIgnoreCase(fleet.tabLabel(), prefix)) {
|
||||||
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" starts with the tabPrefix of "
|
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" starts with the tabPrefix of "
|
||||||
+ "lead '" + leadName + "' (\"" + prefix + "\")");
|
+ "lead '" + leadName + "' (\"" + prefix + "\")");
|
||||||
}
|
}
|
||||||
profiles().entrySet().stream()
|
profiles().entrySet().stream()
|
||||||
.filter(e -> startsWithIgnoreCase(e.getValue().tabLabel(), prefix))
|
|
||||||
.map(Map.Entry::getKey)
|
.map(Map.Entry::getKey)
|
||||||
.sorted()
|
.sorted()
|
||||||
.forEach(p -> bad.add("profile '" + p + "' overrides tabLabel with \""
|
.forEach(p -> {
|
||||||
+ profiles().get(p).tabLabel() + "\", which starts with the tabPrefix of "
|
String label = profiles().get(p).tabLabel();
|
||||||
+ "lead '" + leadName + "' (\"" + prefix + "\")"));
|
if (templateCanRenderAs(label, tab)) {
|
||||||
|
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
|
||||||
|
+ "\", which can render as the tab of lead '" + leadName
|
||||||
|
+ "' (\"" + tab + "\")");
|
||||||
|
} else if (startsWithIgnoreCase(label, prefix)) {
|
||||||
|
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
|
||||||
|
+ "\", which starts with the tabPrefix of lead '" + leadName
|
||||||
|
+ "' (\"" + prefix + "\")");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
if (bad.isEmpty()) {
|
fleet.collaborators().forEach((collabName, collaborator) -> {
|
||||||
|
if (collaborator == null) {
|
||||||
|
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 + "\")");
|
||||||
|
}
|
||||||
|
profiles().entrySet().stream()
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.sorted()
|
||||||
|
.forEach(p -> {
|
||||||
|
String label = profiles().get(p).tabLabel();
|
||||||
|
if (templateCanRenderAs(label, tab)) {
|
||||||
|
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
|
||||||
|
+ "\", which can render as the tab of collaborator '"
|
||||||
|
+ collabName + "' (\"" + tab + "\")");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!bad.isEmpty()) {
|
||||||
|
throw new IllegalStateException("refusing to start: " + String.join("; ", bad)
|
||||||
|
+ ". A member labelled that way, while its pane carries no entry in the "
|
||||||
|
+ "spawned-member roster, is read back as a lead or collaborator and granted "
|
||||||
|
+ "that identity's authority. Change one of the two so member tabs cannot be "
|
||||||
|
+ "confused with a lead's or collaborator's tab.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> collisions = new ArrayList<>();
|
||||||
|
List<String> leadNames = fleet.leaders().keySet().stream().sorted().toList();
|
||||||
|
for (int i = 0; i < leadNames.size(); i++) {
|
||||||
|
String nameA = leadNames.get(i);
|
||||||
|
Leader a = fleet.leaders().get(nameA);
|
||||||
|
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) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> collabNames = fleet.collaborators().keySet().stream().sorted().toList();
|
||||||
|
for (int i = 0; i < collabNames.size(); i++) {
|
||||||
|
String nameA = collabNames.get(i);
|
||||||
|
Collaborator a = fleet.collaborators().get(nameA);
|
||||||
|
if (a == null || a.tab() == null || a.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int j = i + 1; j < collabNames.size(); j++) {
|
||||||
|
String nameB = collabNames.get(j);
|
||||||
|
Collaborator b = fleet.collaborators().get(nameB);
|
||||||
|
if (b == null || b.tab() == null || b.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a.tab().equalsIgnoreCase(b.tab())) {
|
||||||
|
collisions.add("collaborator '" + nameA + "' and collaborator '" + nameB
|
||||||
|
+ "' both use tab \"" + a.tab() + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String leadName : leadNames) {
|
||||||
|
Leader lead = fleet.leaders().get(leadName);
|
||||||
|
if (lead == null || lead.tab() == null || lead.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String collabName : collabNames) {
|
||||||
|
Collaborator collaborator = fleet.collaborators().get(collabName);
|
||||||
|
if (collaborator == null || collaborator.tab() == null
|
||||||
|
|| collaborator.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lead.tab().equalsIgnoreCase(collaborator.tab())) {
|
||||||
|
collisions.add("lead '" + leadName + "' and collaborator '" + collabName
|
||||||
|
+ "' both use tab \"" + lead.tab() + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (collisions.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("refusing to start: " + String.join("; ", bad)
|
throw new IllegalStateException("refusing to start: " + String.join("; ", collisions)
|
||||||
+ ". Every member labelled that way would be read back as a lead and granted "
|
+ ". Identity is matched exactly, so only one of two entries sharing a space or a "
|
||||||
+ "spawn/stop/send on the whole fleet. Change one of the two so member tabs and "
|
+ "tab can ever be found — the other is silently unreachable. Give each lead its "
|
||||||
+ "lead tabs cannot be confused.");
|
+ "own space, and each collaborator its own exact tab.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean templateCanRenderAs(String template, String tab) {
|
||||||
|
if (template == null || template.isBlank() || tab == null || tab.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var placeholders = Pattern.compile("\\{(?:role|profile|model|n)}").matcher(template);
|
||||||
|
StringBuilder expression = new StringBuilder("^");
|
||||||
|
int literalStart = 0;
|
||||||
|
while (placeholders.find()) {
|
||||||
|
expression.append(Pattern.quote(template.substring(literalStart, placeholders.start())));
|
||||||
|
expression.append(".*");
|
||||||
|
literalStart = placeholders.end();
|
||||||
|
}
|
||||||
|
expression.append(Pattern.quote(template.substring(literalStart))).append("$");
|
||||||
|
return Pattern.compile(expression.toString(), Pattern.CASE_INSENSITIVE).matcher(tab).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive prefix test that tolerates a null or blank label. */
|
||||||
|
private static boolean startsWithIgnoreCase(String label, String prefix) {
|
||||||
|
if (label == null || prefix == null || prefix.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String stripped = label.strip();
|
||||||
|
return stripped.regionMatches(true, 0, prefix, 0, prefix.length());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a profile that places its members by {@code "pane"} while any {@code fleet.leaders}
|
* Reject a profile that places its members by {@code "pane"} while {@code fleet.leaders} has
|
||||||
* entry names a {@code tab}. A pane-placed member lands inside the focused tab rather than its
|
* any entry, or any {@code fleet.collaborators} entry names a {@code tab}. A pane-placed
|
||||||
* own, so it can land inside a lead's own labelled tab. {@link
|
* member lands inside the focused tab rather than its own, so it can land inside a lead's or
|
||||||
* dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead purely by that tab's label — it does
|
* collaborator's own labelled tab. {@link dev.ltms.fleet.herdr.LeadTabScanner} identifies a
|
||||||
* not exclude the member space — so a member that ends up there would be read back as the lead
|
* lead or collaborator purely by that tab's label — it does not exclude the member space — so
|
||||||
* and granted spawn/stop/send on the whole fleet.
|
* 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 a leader 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.
|
* 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
|
* @throws IllegalStateException when any {@code profiles:} entry is pane-placed while
|
||||||
* {@code fleet.leaders} entry names a non-blank {@code tab}
|
* {@code fleet.leaders} is non-empty, or any
|
||||||
|
* {@code fleet.collaborators} entry names a non-blank
|
||||||
|
* {@code tab}
|
||||||
*/
|
*/
|
||||||
public void validatePanePlacementAgainstLeadTabs() {
|
public void validatePanePlacementAgainstLeadTabs() {
|
||||||
if (fleet == null || fleet.leaders().isEmpty()) {
|
if (fleet == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean anyLeaderHasTab = fleet.leaders().values().stream()
|
boolean anyLead = !fleet.leaders().isEmpty();
|
||||||
.anyMatch(leader -> leader != null && leader.tab() != null && !leader.tab().isBlank());
|
boolean anyCollaboratorHasTab = fleet.collaborators().values().stream()
|
||||||
if (!anyLeaderHasTab) {
|
.anyMatch(c -> c != null && c.tab() != null && !c.tab().isBlank());
|
||||||
|
if (!anyLead && !anyCollaboratorHasTab) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<String> bad = new ArrayList<>();
|
List<String> bad = new ArrayList<>();
|
||||||
@@ -2771,10 +2995,13 @@ public record FleetConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("refusing to start: profile(s) " + bad
|
throw new IllegalStateException("refusing to start: profile(s) " + bad
|
||||||
+ " use placement: pane while fleet.leaders names a tab. A pane-placed member can "
|
+ " use placement: pane while fleet.leaders or fleet.collaborators names a tab. A "
|
||||||
+ "land inside a lead's labelled tab and be read back as the lead, granted "
|
+ "pane-placed member can land inside that labelled tab, and while its pane "
|
||||||
+ "spawn/stop/send on the whole fleet. Set placement: tab for each named profile, "
|
+ "carries no entry in the spawned-member roster, it is read back as the lead or "
|
||||||
+ "or remove the tab from every fleet.leaders entry.");
|
+ "collaborator and granted that identity's authority. Set placement: tab for "
|
||||||
|
+ "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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2797,15 +3024,6 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Case-insensitive prefix test that tolerates a null/blank label. */
|
|
||||||
private static boolean startsWithIgnoreCase(String label, String prefix) {
|
|
||||||
if (label == null || prefix == null || prefix.isBlank()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String stripped = label.strip();
|
|
||||||
return stripped.regionMatches(true, 0, prefix, 0, prefix.length());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a subscription profile whose {@code env:} block tries to reseat the Anthropic binding
|
* Reject a subscription profile whose {@code env:} block tries to reseat the Anthropic binding
|
||||||
* (CB-542).
|
* (CB-542).
|
||||||
@@ -2890,8 +3108,13 @@ public record FleetConfig(
|
|||||||
* so duplicates are unrepresentable by construction once loaded — and {@link #load(Path)}
|
* 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.
|
* already rejects a duplicated slot name at parse time, before the map collapses.
|
||||||
*
|
*
|
||||||
* @throws IllegalStateException when a slot names no profile or an unknown one, or when a lead
|
* <p>A lead's {@code profile} is optional — a {@code profile}-less lead is still useful
|
||||||
* can be neither found nor created, naming the offending entry
|
* 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 or a lead references an unknown profile, or a
|
||||||
|
* collaborator names no tab, naming the offending entry
|
||||||
*/
|
*/
|
||||||
public void validateMembers() {
|
public void validateMembers() {
|
||||||
if (fleet == null) {
|
if (fleet == null) {
|
||||||
@@ -2924,9 +3147,15 @@ public record FleetConfig(
|
|||||||
+ "', which is not a configured profiles: entry (have: " + profiles.keySet()
|
+ "', 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 "
|
fleet.collaborators().forEach((name, collaborator) -> {
|
||||||
+ "auto-launched, labelled) purely by its tab, so every entry must name one.");
|
if (collaborator == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (collaborator.tab() == null || collaborator.tab().isBlank()) {
|
||||||
|
bad.add("fleet.collaborators." + name + " has no tab: — a collaborator is "
|
||||||
|
+ "recognised purely by its tab, and carries no other field, so every "
|
||||||
|
+ "entry must name one.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!bad.isEmpty()) {
|
if (!bad.isEmpty()) {
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ public final class AgentControl {
|
|||||||
return result.path("read").path("text").asText("");
|
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). */
|
/** Current agent record (status, session UUID, pane). */
|
||||||
public Agent get(String target) {
|
public Agent get(String target) {
|
||||||
return Agent.from(agentCall("agent.get", target, Map.of()).get("agent"));
|
return Agent.from(agentCall("agent.get", target, Map.of()).get("agent"));
|
||||||
|
|||||||
@@ -11,12 +11,18 @@ public final class HerdrRouter implements AutoCloseable {
|
|||||||
private final AgentControl memberAgents;
|
private final AgentControl memberAgents;
|
||||||
private final WorkspaceControl leadSpaces;
|
private final WorkspaceControl leadSpaces;
|
||||||
private final WorkspaceControl memberSpaces;
|
private final WorkspaceControl memberSpaces;
|
||||||
private final Predicate<String> isLead;
|
private final Predicate<String> routeToLead;
|
||||||
|
|
||||||
public HerdrRouter(HerdrClient lead, HerdrClient member, Predicate<String> isLead) {
|
/**
|
||||||
|
* @param routeToLead true for a terminal whose pane lives in the lead herdr daemon — a lead's
|
||||||
|
* own pane or a configured collaborator's, both opened by a person at a
|
||||||
|
* terminal rather than spawned, so both are found in the lead daemon rather
|
||||||
|
* than the member one
|
||||||
|
*/
|
||||||
|
public HerdrRouter(HerdrClient lead, HerdrClient member, Predicate<String> routeToLead) {
|
||||||
this.lead = Objects.requireNonNull(lead, "lead");
|
this.lead = Objects.requireNonNull(lead, "lead");
|
||||||
this.member = member != null ? member : lead;
|
this.member = member != null ? member : lead;
|
||||||
this.isLead = Objects.requireNonNull(isLead, "isLead");
|
this.routeToLead = Objects.requireNonNull(routeToLead, "routeToLead");
|
||||||
leadAgents = new AgentControl(this.lead);
|
leadAgents = new AgentControl(this.lead);
|
||||||
memberAgents = this.member == this.lead ? leadAgents : new AgentControl(this.member);
|
memberAgents = this.member == this.lead ? leadAgents : new AgentControl(this.member);
|
||||||
leadSpaces = new WorkspaceControl(this.lead);
|
leadSpaces = new WorkspaceControl(this.lead);
|
||||||
@@ -27,7 +33,7 @@ public final class HerdrRouter implements AutoCloseable {
|
|||||||
public WorkspaceControl leadSpaces() { return leadSpaces; }
|
public WorkspaceControl leadSpaces() { return leadSpaces; }
|
||||||
public AgentControl memberAgents() { return memberAgents; }
|
public AgentControl memberAgents() { return memberAgents; }
|
||||||
public WorkspaceControl memberSpaces() { return memberSpaces; }
|
public WorkspaceControl memberSpaces() { return memberSpaces; }
|
||||||
public AgentControl agentsFor(String targetId) { return isLead.test(targetId) ? leadAgents : memberAgents; }
|
public AgentControl agentsFor(String targetId) { return routeToLead.test(targetId) ? leadAgents : memberAgents; }
|
||||||
|
|
||||||
HerdrClient leadClient() { return lead; }
|
HerdrClient leadClient() { return lead; }
|
||||||
HerdrClient memberClient() { return member; }
|
HerdrClient memberClient() { return member; }
|
||||||
|
|||||||
@@ -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
|
* by first starting the session and asking it. Scanning closes that loop: label the tab, and the
|
||||||
* pane is recognised on the next resolve.
|
* pane is recognised on the next resolve.
|
||||||
*
|
*
|
||||||
* <p><strong>CB-579 — matched by name, not prefix.</strong> This used to strip one shared
|
* <p><strong>Matched by label within a space, not by a shared prefix.</strong> Each lead's accepted
|
||||||
* {@code tabPrefix} off a label to derive the lead's name, and merged a config-supplied
|
* labels (the fixed {@code lead} label, plus a deprecated {@code tab} when still configured) are
|
||||||
* {@code terminal_id} pin over every scan result so the pin could never expire. Both are gone: each
|
* matched exactly (case-insensitively) against tabs in that lead's own space only — a tab named
|
||||||
* lead now configures its own exact {@code tab} label ({@code fleet.leaders.<name>.tab}), so this
|
* {@code lead} in one space never resolves to another space's lead. A scan result is the whole
|
||||||
* class is handed a {@code tab → name} map up front and matches labels against it exactly
|
* answer; nothing is merged in from configuration between scans, so a tab that is gone drops out on
|
||||||
* (case-insensitively). There is no merge step — a scan result is the whole answer. That is the
|
* the very next scan instead of lingering forever.
|
||||||
* 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>Direction of trust.</strong> The label names the lead; it never <em>grants</em>
|
* <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:
|
* anything a pane could take for itself. Three properties keep that honest:
|
||||||
@@ -96,13 +94,20 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(LeadTabScanner.class);
|
private static final Logger log = LoggerFactory.getLogger(LeadTabScanner.class);
|
||||||
|
|
||||||
|
/** What a matched tab names: a lead or a collaborator. */
|
||||||
|
private enum Kind { LEAD, COLLABORATOR }
|
||||||
|
|
||||||
|
/** One matched tab's name and what it names. */
|
||||||
|
private record Entry(String name, Kind kind) {}
|
||||||
|
|
||||||
private final HerdrClient herdr;
|
private final HerdrClient herdr;
|
||||||
private final Map<String, String> tabToName;
|
private final Map<String, Map<String, String>> leadLabelsBySpace;
|
||||||
|
private final Map<String, String> collaboratorTabToName;
|
||||||
private final Set<String> excludedWorkspaceLabels;
|
private final Set<String> excludedWorkspaceLabels;
|
||||||
private final long ttlNanos;
|
private final long ttlNanos;
|
||||||
private final LongSupplier clock;
|
private final LongSupplier clock;
|
||||||
|
|
||||||
private Map<String, String> cached = Map.of();
|
private Map<String, Entry> cached = Map.of();
|
||||||
private long scannedAtNanos;
|
private long scannedAtNanos;
|
||||||
private boolean everScanned;
|
private boolean everScanned;
|
||||||
|
|
||||||
@@ -118,36 +123,82 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
|||||||
/**
|
/**
|
||||||
* @param herdr the herdr client to query ({@code workspace.list},
|
* @param herdr the herdr client to query ({@code workspace.list},
|
||||||
* {@code tab.list}, {@code pane.list} — all read-only)
|
* {@code tab.list}, {@code pane.list} — all read-only)
|
||||||
* @param tabToName every configured lead's exact tab label → its name
|
* @param leadLabelsBySpace each configured lead's accepted tab labels, keyed by the
|
||||||
* ({@code fleet.leaders.<name>.tab}), matched case-insensitively
|
* 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 excludedWorkspaceLabels workspaces never scanned — the configured worker spaces
|
||||||
* @param ttlNanos how long a scan result is reused before the next one
|
* @param ttlNanos how long a scan result is reused before the next one
|
||||||
* @param clock nanosecond time source ({@code System::nanoTime} in production)
|
* @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, leadLabelsBySpace, Map.of(), excludedWorkspaceLabels, ttlNanos, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #LeadTabScanner(HerdrClient, Map, Set, long, LongSupplier)}, additionally scanning
|
||||||
|
* 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
|
||||||
|
* case-insensitively in any space
|
||||||
|
*/
|
||||||
|
public LeadTabScanner(HerdrClient herdr, Map<String, Map<String, String>> leadLabelsBySpace,
|
||||||
|
Map<String, String> collaboratorTabToName,
|
||||||
Set<String> excludedWorkspaceLabels, long ttlNanos, LongSupplier clock) {
|
Set<String> excludedWorkspaceLabels, long ttlNanos, LongSupplier clock) {
|
||||||
this.herdr = herdr;
|
this.herdr = herdr;
|
||||||
this.tabToName = normalize(tabToName);
|
this.leadLabelsBySpace = buildLeadIndex(leadLabelsBySpace);
|
||||||
|
this.collaboratorTabToName = normalizedLabelMap(collaboratorTabToName);
|
||||||
this.excludedWorkspaceLabels = excludedWorkspaceLabels == null
|
this.excludedWorkspaceLabels = excludedWorkspaceLabels == null
|
||||||
? Set.of() : Set.copyOf(excludedWorkspaceLabels);
|
? Set.of() : Set.copyOf(excludedWorkspaceLabels);
|
||||||
this.ttlNanos = ttlNanos;
|
this.ttlNanos = ttlNanos;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keys stripped and lower-cased once, so every lookup is a plain map hit. */
|
/**
|
||||||
private static Map<String, String> normalize(Map<String, String> tabToName) {
|
* Space and label keys stripped and lower-cased once, so every lookup is a plain map hit. A
|
||||||
if (tabToName == null || tabToName.isEmpty()) {
|
* 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, Map<String, String>> buildLeadIndex(
|
||||||
|
Map<String, Map<String, String>> leadLabelsBySpace) {
|
||||||
|
Map<String, Map<String, String>> out = new LinkedHashMap<>();
|
||||||
|
if (leadLabelsBySpace == null) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
}
|
}
|
||||||
Map<String, String> out = new LinkedHashMap<>();
|
leadLabelsBySpace.forEach((space, labelsToName) -> {
|
||||||
tabToName.forEach((tab, name) -> {
|
if (space == null || space.isBlank()) {
|
||||||
if (tab != null && !tab.isBlank() && name != null && !name.isBlank()) {
|
return;
|
||||||
out.put(tab.strip().toLowerCase(Locale.ROOT), name);
|
}
|
||||||
|
Map<String, String> normalized = normalizedLabelMap(labelsToName);
|
||||||
|
if (!normalized.isEmpty()) {
|
||||||
|
out.put(space.strip().toLowerCase(Locale.ROOT), normalized);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Collections.unmodifiableMap(out);
|
return Collections.unmodifiableMap(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current {@code terminal_id → lead name} map, rescanning when the cache has expired.
|
* The current {@code terminal_id → lead name} map, rescanning when the cache has expired.
|
||||||
*
|
*
|
||||||
@@ -156,6 +207,29 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public synchronized Map<String, String> get() {
|
public synchronized Map<String, String> get() {
|
||||||
|
return byKind(refresh(), Kind.LEAD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current {@code terminal_id → collaborator name} map, sharing the same scan and cache as
|
||||||
|
* {@link #get()} — both kinds are matched in one pass, so this never costs a second herdr call.
|
||||||
|
*/
|
||||||
|
public synchronized Map<String, String> collaborators() {
|
||||||
|
return byKind(refresh(), Kind.COLLABORATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> byKind(Map<String, Entry> entries, Kind kind) {
|
||||||
|
Map<String, String> out = new LinkedHashMap<>();
|
||||||
|
entries.forEach((terminal, entry) -> {
|
||||||
|
if (entry.kind() == kind) {
|
||||||
|
out.put(terminal, entry.name());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Collections.unmodifiableMap(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rescans if the cache has expired, otherwise returns the cached answer. */
|
||||||
|
private Map<String, Entry> refresh() {
|
||||||
long now = clock.getAsLong();
|
long now = clock.getAsLong();
|
||||||
if (everScanned && now - scannedAtNanos < ttlNanos) {
|
if (everScanned && now - scannedAtNanos < ttlNanos) {
|
||||||
return cached;
|
return cached;
|
||||||
@@ -165,43 +239,45 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
|||||||
scannedAtNanos = now;
|
scannedAtNanos = now;
|
||||||
everScanned = true;
|
everScanned = true;
|
||||||
try {
|
try {
|
||||||
Map<String, String> fresh = scan();
|
Map<String, Entry> fresh = scan();
|
||||||
if (!fresh.equals(cached)) {
|
if (!fresh.equals(cached)) {
|
||||||
log.info("lead panes: {}", fresh);
|
log.info("lead/collaborator panes: {}", fresh);
|
||||||
}
|
}
|
||||||
cached = fresh;
|
cached = fresh;
|
||||||
} catch (HerdrException e) {
|
} catch (HerdrException e) {
|
||||||
log.warn("lead-tab scan failed, keeping the {} lead(s) already known: {}",
|
log.warn("lead-tab scan failed, keeping the {} entr(y/ies) already known: {}",
|
||||||
cached.size(), e.getMessage());
|
cached.size(), e.getMessage());
|
||||||
}
|
}
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One full pass: labelled tabs → live agents in them → those panes' terminals. */
|
/** One full pass: labelled tabs → live agents in them → those panes' terminals. */
|
||||||
private Map<String, String> scan() {
|
private Map<String, Entry> scan() {
|
||||||
Map<String, String> nameByTab = new LinkedHashMap<>();
|
Map<String, Entry> entryByTab = new LinkedHashMap<>();
|
||||||
for (JsonNode w : herdr.call("workspace.list").path("workspaces")) {
|
for (JsonNode w : herdr.call("workspace.list").path("workspaces")) {
|
||||||
Workspace ws = Workspace.from(w);
|
Workspace ws = Workspace.from(w);
|
||||||
if (ws.workspaceId() == null || excludedWorkspaceLabels.contains(ws.label())) {
|
if (ws.workspaceId() == null || excludedWorkspaceLabels.contains(ws.label())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
Map<String, String> leadLabelsHere = leadLabelsFor(ws.label());
|
||||||
for (JsonNode t : herdr.call("tab.list", Map.of("workspace_id", ws.workspaceId())).path("tabs")) {
|
for (JsonNode t : herdr.call("tab.list", Map.of("workspace_id", ws.workspaceId())).path("tabs")) {
|
||||||
Tab tab = Tab.from(t);
|
Tab tab = Tab.from(t);
|
||||||
String name = leadNameOf(tab.label());
|
Entry entry = entryOf(tab.label(), leadLabelsHere);
|
||||||
if (name != null && tab.tabId() != null) {
|
if (entry != null && tab.tabId() != null) {
|
||||||
nameByTab.put(tab.tabId(), name);
|
entryByTab.put(tab.tabId(), entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nameByTab.isEmpty()) {
|
if (entryByTab.isEmpty()) {
|
||||||
gracedTerminals = Set.of();
|
gracedTerminals = Set.of();
|
||||||
return Map.of();
|
return Map.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
// fleetd #359: a labelled tab is only a lead when herdr also reports a running agent in
|
// fleetd #359: a labelled tab is only a lead (or collaborator) when herdr also reports a
|
||||||
// it — the same liveness signal LeadLauncher.countLeads trusts for the identical purpose.
|
// running agent in it — the same liveness signal LeadLauncher.countLeads trusts for the
|
||||||
// Without this, a tab left behind by a session that has since died reads as live forever.
|
// identical purpose. Without this, a tab left behind by a session that has since died reads
|
||||||
|
// as live forever.
|
||||||
Set<String> tabsWithAgent = new HashSet<>();
|
Set<String> tabsWithAgent = new HashSet<>();
|
||||||
for (JsonNode a : herdr.call("agent.list").path("agents")) {
|
for (JsonNode a : herdr.call("agent.list").path("agents")) {
|
||||||
String tabId = a.path("tab_id").asText(null);
|
String tabId = a.path("tab_id").asText(null);
|
||||||
@@ -210,18 +286,18 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, String> byTerminal = new LinkedHashMap<>();
|
Map<String, Entry> byTerminal = new LinkedHashMap<>();
|
||||||
Set<String> stillGraced = new HashSet<>();
|
Set<String> stillGraced = new HashSet<>();
|
||||||
// One pane.list for every tab: panes carry tab_id, so the join is local.
|
// One pane.list for every tab: panes carry tab_id, so the join is local.
|
||||||
for (JsonNode p : herdr.call("pane.list", Map.of()).path("panes")) {
|
for (JsonNode p : herdr.call("pane.list", Map.of()).path("panes")) {
|
||||||
String tabId = p.path("tab_id").asText(null);
|
String tabId = p.path("tab_id").asText(null);
|
||||||
String name = nameByTab.get(tabId);
|
Entry entry = entryByTab.get(tabId);
|
||||||
String terminal = p.path("terminal_id").asText(null);
|
String terminal = p.path("terminal_id").asText(null);
|
||||||
if (name == null || terminal == null || terminal.isBlank()) {
|
if (entry == null || terminal == null || terminal.isBlank()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tabsWithAgent.contains(tabId)) {
|
if (tabsWithAgent.contains(tabId)) {
|
||||||
byTerminal.put(terminal, name);
|
byTerminal.put(terminal, entry);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// No agent reported for this tab, but its tab/pane are still here — this is the
|
// No agent reported for this tab, but its tab/pane are still here — this is the
|
||||||
@@ -230,7 +306,7 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
|||||||
// reported as live; a terminal we never reported live gets none, so the original #359
|
// reported as live; a terminal we never reported live gets none, so the original #359
|
||||||
// fix (a genuinely dead tab is never reported) is unaffected for the common case.
|
// fix (a genuinely dead tab is never reported) is unaffected for the common case.
|
||||||
if (cached.containsKey(terminal) && !gracedTerminals.contains(terminal)) {
|
if (cached.containsKey(terminal) && !gracedTerminals.contains(terminal)) {
|
||||||
byTerminal.put(terminal, name);
|
byTerminal.put(terminal, entry);
|
||||||
stillGraced.add(terminal);
|
stillGraced.add(terminal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,18 +315,27 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lead name a tab label declares, or {@code null} if it names none of the configured leads.
|
* 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 #tabToName} — no prefix
|
* <p>Exact match (case-insensitive, ends stripped) — no prefix stripping, so an operator's
|
||||||
* stripping, so an operator's {@code "lead: something-else"} tab is never mistaken for a
|
* {@code "lead: something-else"} tab is never mistaken for a configured lead just because it
|
||||||
* configured lead just because it shares a prefix. The match strips a trailing
|
* shares a prefix. The match strips a trailing {@link PendingCloseMarker} first, so a tab
|
||||||
* {@link PendingCloseMarker} first, so a tab {@code LeadLauncher} has flagged as maybe-dead but
|
* {@code LeadLauncher} has flagged as maybe-dead but not yet closed keeps resolving normally
|
||||||
* not yet closed keeps resolving normally while that reconcile is pending.
|
* 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 String leadNameOf(String label) {
|
private Entry entryOf(String label, Map<String, String> leadLabelsHere) {
|
||||||
if (label == null) {
|
if (label == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return tabToName.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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -145,6 +146,52 @@ public final class PaneLocator {
|
|||||||
return ancestry;
|
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. */
|
/** 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 }
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package dev.ltms.fleet.herdr;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.IntFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three protections every {@code agent.start} caller needs against herdr's pane-typed launch
|
||||||
|
* surface (fleetd #220, #727): a byte-limit check on the assembled command line, a bounded retry
|
||||||
|
* on {@code agent_pane_busy} (the target pane's shell has not reached its prompt yet), and a
|
||||||
|
* bounded retry on {@code agent_name_taken} with a fresh name each attempt. One implementation —
|
||||||
|
* every caller of {@code agent.start}, lead or member, goes through this seam rather than carrying
|
||||||
|
* its own copy.
|
||||||
|
*/
|
||||||
|
public final class ResilientAgentLaunch {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ResilientAgentLaunch.class);
|
||||||
|
|
||||||
|
private ResilientAgentLaunch() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pty line buffer herdr types a launch command into: BSD/macOS {@code MAX_CANON}. Not a
|
||||||
|
* fleetd choice and not configurable — see {@link #checkFits}.
|
||||||
|
*/
|
||||||
|
public static final int PANE_COMMAND_BYTE_LIMIT = 1024;
|
||||||
|
|
||||||
|
/** Per-argument allowance for the separating space and a shell quote pair fleetd cannot see. */
|
||||||
|
private static final int QUOTING_OVERHEAD_PER_ARG = 3;
|
||||||
|
|
||||||
|
/** herdr rejects a duplicate agent {@code name}; a caller retries a bumped name this many times. */
|
||||||
|
public static final int NAME_RETRIES = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries for {@code agent.start} against a seed pane whose shell has not reached its prompt
|
||||||
|
* yet — {@code tab.create}/{@code pane.split} return as soon as the pane exists, and herdr
|
||||||
|
* refuses to start an agent in a pane that is not "an available shell" ({@code agent_pane_busy}).
|
||||||
|
*/
|
||||||
|
public static final int SHELL_READY_RETRIES = 20;
|
||||||
|
|
||||||
|
/** Raised by {@link #checkFits} when the assembled command cannot fit the pane line. */
|
||||||
|
public static final class TooLargeException extends RuntimeException {
|
||||||
|
public TooLargeException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the assembled launch line fits the pane herdr types it into. herdr does not exec the
|
||||||
|
* launch command — it TYPES it into the pane as one line, and a pty line buffer holds only
|
||||||
|
* {@value #PANE_COMMAND_BYTE_LIMIT} bytes. Everything past that byte is dropped with no error
|
||||||
|
* anywhere: herdr answers "agent started", the backend exits on the mangled argument it was
|
||||||
|
* handed, the pane closes, and the only symptom is a readiness timeout with no reason. That is
|
||||||
|
* how fleetd #214 broke every claude-code spawn — one 50-byte flag pushed a 978-byte command to
|
||||||
|
* 1028, and the tail that got cut was {@code --autocompact 250000}.
|
||||||
|
*
|
||||||
|
* <p>So measure it here and refuse, loudly and immediately, rather than start something that
|
||||||
|
* cannot work. The estimate is deliberately conservative: fleetd cannot see herdr's quoting, so
|
||||||
|
* every argument is charged its own bytes plus a separator and a quote pair. An over-estimate
|
||||||
|
* costs a clear error at a length that was already unsafe; an under-estimate would let the
|
||||||
|
* silent truncation back in.
|
||||||
|
*
|
||||||
|
* @param label names the launch in the refusal message (a profile name)
|
||||||
|
* @param argv the full argv, including the executable at index 0
|
||||||
|
* @throws TooLargeException naming the limit, the estimate, and the longest argument
|
||||||
|
*/
|
||||||
|
public static void checkFits(String label, List<String> argv) {
|
||||||
|
int bytes = 0;
|
||||||
|
String longest = null;
|
||||||
|
int longestBytes = 0;
|
||||||
|
for (String arg : argv) {
|
||||||
|
int argBytes = arg == null ? 0 : arg.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
bytes += argBytes + QUOTING_OVERHEAD_PER_ARG;
|
||||||
|
if (argBytes > longestBytes) {
|
||||||
|
longestBytes = argBytes;
|
||||||
|
longest = arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes <= PANE_COMMAND_BYTE_LIMIT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String culprit = longest == null ? "<none>"
|
||||||
|
: longest.substring(0, Math.min(longest.length(), 60)) + (longest.length() > 60 ? "…" : "");
|
||||||
|
throw new TooLargeException(
|
||||||
|
"launch command for " + label + " is about " + bytes + " bytes, over the "
|
||||||
|
+ PANE_COMMAND_BYTE_LIMIT + "-byte limit of the pane line herdr types it into. "
|
||||||
|
+ "The pty would drop the tail silently and the backend would exit on a mangled "
|
||||||
|
+ "argument. Longest argument is " + longestBytes + " bytes: " + culprit
|
||||||
|
+ " — move it off the command line (a file flag) or shorten it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an agent into {@code paneId}, retrying {@code agent_pane_busy} up to {@code retries}
|
||||||
|
* times with {@code sleeper} run between attempts.
|
||||||
|
*
|
||||||
|
* @throws HerdrException the last {@code agent_pane_busy} failure once {@code retries} is
|
||||||
|
* spent, or immediately for any other herdr failure
|
||||||
|
*/
|
||||||
|
public static Agent startAwaitingShellPrompt(AgentControl agents, String name, String kind,
|
||||||
|
List<String> args, String paneId,
|
||||||
|
int retries, Runnable sleeper) {
|
||||||
|
HerdrException busy = null;
|
||||||
|
for (int attempt = 0; attempt < retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return agents.start(name, kind, args, paneId);
|
||||||
|
} catch (HerdrException e) {
|
||||||
|
if (!"agent_pane_busy".equals(e.code())) throw e;
|
||||||
|
log.debug("pane {} not at its shell prompt yet, retrying agent.start", paneId);
|
||||||
|
busy = e;
|
||||||
|
sleeper.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an agent under a freshly generated name each attempt, retrying {@code agent_name_taken}
|
||||||
|
* up to {@code nameRetries} times — herdr refuses a duplicate {@code name} outright, so a stale
|
||||||
|
* registry entry (a crashed session, a name the registry has not yet released) must not block a
|
||||||
|
* legitimate relaunch. Each attempt also carries its own {@link #startAwaitingShellPrompt} retry.
|
||||||
|
*
|
||||||
|
* @param nameForAttempt called once per attempt (0-based) to produce that attempt's name
|
||||||
|
* @throws HerdrException the last {@code agent_name_taken} failure once {@code nameRetries} is
|
||||||
|
* spent, or immediately for any other herdr failure
|
||||||
|
*/
|
||||||
|
public static Agent startUniquelyNamed(AgentControl agents, String kind, List<String> args,
|
||||||
|
String paneId, IntFunction<String> nameForAttempt,
|
||||||
|
int nameRetries, int shellReadyRetries, Runnable sleeper) {
|
||||||
|
HerdrException last = null;
|
||||||
|
for (int attempt = 0; attempt < nameRetries; attempt++) {
|
||||||
|
String name = nameForAttempt.apply(attempt);
|
||||||
|
try {
|
||||||
|
return startAwaitingShellPrompt(agents, name, kind, args, paneId,
|
||||||
|
shellReadyRetries, sleeper);
|
||||||
|
} catch (HerdrException e) {
|
||||||
|
if (!"agent_name_taken".equals(e.code())) throw e;
|
||||||
|
log.debug("agent name '{}' taken, retrying", name);
|
||||||
|
last = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw last;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,17 @@ public final class Injector {
|
|||||||
*/
|
*/
|
||||||
private static final int READINESS_GRACE_POLLS = 240;
|
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
|
* 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,
|
* {@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.
|
* already uses for the same purpose.
|
||||||
*/
|
*/
|
||||||
private final LongSupplier nowMillis;
|
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<>();
|
private final ConcurrentHashMap<String, Target> targets = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/** Delivery only; completion signalling is a no-op and every target is treated as available. */
|
/** 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.ready = ready;
|
||||||
this.forget = forget;
|
this.forget = forget;
|
||||||
this.nowMillis = nowMillis;
|
this.nowMillis = nowMillis;
|
||||||
|
this.paneInbox = new PaneInbox(nowMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Injector(HerdrRouter router, TurnListener turnListener, Predicate<String> ready,
|
public Injector(HerdrRouter router, TurnListener turnListener, Predicate<String> ready,
|
||||||
@@ -221,6 +239,7 @@ public final class Injector {
|
|||||||
this.ready = ready;
|
this.ready = ready;
|
||||||
this.forget = forget;
|
this.forget = forget;
|
||||||
this.nowMillis = nowMillis;
|
this.nowMillis = nowMillis;
|
||||||
|
this.paneInbox = new PaneInbox(nowMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentControl agentsFor(String target) {
|
private AgentControl agentsFor(String target) {
|
||||||
@@ -296,13 +315,16 @@ public final class Injector {
|
|||||||
final String text;
|
final String text;
|
||||||
final TurnToken token;
|
final TurnToken token;
|
||||||
final CompletableFuture<Void> delivered;
|
final CompletableFuture<Void> delivered;
|
||||||
|
final long enqueuedAtMillis;
|
||||||
volatile State state = State.QUEUED; // written under the owning Target monitor
|
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.target = target;
|
||||||
this.text = text;
|
this.text = text;
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.delivered = delivered;
|
this.delivered = delivered;
|
||||||
|
this.enqueuedAtMillis = enqueuedAtMillis;
|
||||||
}
|
}
|
||||||
|
|
||||||
String text() {
|
String text() {
|
||||||
@@ -329,10 +351,15 @@ public final class Injector {
|
|||||||
int unknownSincePostTurn; // the same, for the post-turn housekeeping phase (fleetd #306)
|
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)
|
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
|
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 postTurnPending; // completion observed; adapter housekeeping has not started yet
|
||||||
boolean awaitingPostTurnPickup;
|
boolean awaitingPostTurnPickup;
|
||||||
boolean postTurnObserved;
|
boolean postTurnObserved;
|
||||||
int injectableSincePostTurnPickup;
|
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) {
|
synchronized void add(Pending p) {
|
||||||
queue.add(p);
|
queue.add(p);
|
||||||
@@ -349,7 +376,7 @@ public final class Injector {
|
|||||||
*/
|
*/
|
||||||
public Delivery enqueue(String target, String text, TurnToken token) {
|
public Delivery enqueue(String target, String text, TurnToken token) {
|
||||||
CompletableFuture<Void> delivered = new CompletableFuture<>();
|
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) -> {
|
targets.compute(target, (_, existing) -> {
|
||||||
Target t = (existing != null) ? existing : new Target();
|
Target t = (existing != null) ? existing : new Target();
|
||||||
t.add(p); // synchronized on the Target monitor — atomic with a concurrent drop
|
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
|
* Cancel this exact queued delivery. The target monitor serializes this operation with
|
||||||
* {@link #onStatus}: if delivery wins that race, this returns {@link Cancellation#DELIVERED}
|
* {@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) {
|
public Cancellation cancel(Delivery delivery) {
|
||||||
Pending p = delivery.pending;
|
Pending p = delivery.pending;
|
||||||
@@ -370,7 +398,22 @@ public final class Injector {
|
|||||||
return cancellationOf(p);
|
return cancellationOf(p);
|
||||||
}
|
}
|
||||||
synchronized (t) {
|
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);
|
return cancellationOf(p);
|
||||||
}
|
}
|
||||||
p.state = Pending.State.CANCELLED;
|
p.state = Pending.State.CANCELLED;
|
||||||
@@ -415,6 +458,7 @@ public final class Injector {
|
|||||||
boolean resubmit = false;
|
boolean resubmit = false;
|
||||||
boolean startPostTurn = false;
|
boolean startPostTurn = false;
|
||||||
List<Pending> notReady = null; // queued messages failed because the worker never became ready
|
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) {
|
synchronized (t) {
|
||||||
if (status == AgentStatus.WORKING) {
|
if (status == AgentStatus.WORKING) {
|
||||||
if (t.awaitingPostTurnPickup) {
|
if (t.awaitingPostTurnPickup) {
|
||||||
@@ -454,10 +498,14 @@ public final class Injector {
|
|||||||
t.injectableSincePickup = 0;
|
t.injectableSincePickup = 0;
|
||||||
t.awaitingCompletion = false;
|
t.awaitingCompletion = false;
|
||||||
t.turnObserved = false;
|
t.turnObserved = false;
|
||||||
} else {
|
} else if (!t.deliveredViaInbox) {
|
||||||
// Delivered but still idle → the worker hasn't picked it up; the submit
|
// 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).
|
// 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.
|
// 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;
|
resubmit = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,45 +530,77 @@ public final class Injector {
|
|||||||
if (p != null && ready.test(target)) {
|
if (p != null && ready.test(target)) {
|
||||||
t.notReadySincePoll = 0;
|
t.notReadySincePoll = 0;
|
||||||
t.notReadySinceMillis = 0;
|
t.notReadySinceMillis = 0;
|
||||||
// fleetd #551: poll and record BEFORE the irreversible send, not after.
|
boolean modServed = paneInbox.isModServed(target);
|
||||||
// The entry comes off the queue and its state is set to ATTEMPTED here,
|
if (t.inboxOffer != null && !modServed) {
|
||||||
// unconditionally — so a Throwable escaping the send call below (caught or
|
// The pane stopped collecting its mail, so take the offer back and
|
||||||
// not) can never leave the entry QUEUED at the head of t.queue (the fleetd
|
// fall through to the terminal route below. withdrawAll leaves an
|
||||||
// #546 hazard, since peek() alone would let the next onStatus round re-enter
|
// entry the pane took first alone, so the branch under it still sees
|
||||||
// this block and send the same text again), and no path can write a
|
// that as the delivery it is.
|
||||||
// confident DELIVERED or NOT_DELIVERED before we actually know which one
|
paneInbox.withdrawAll(target);
|
||||||
// happened.
|
if (!t.inboxOffer.taken()) {
|
||||||
t.queue.poll();
|
t.inboxOffer = null;
|
||||||
p.state = Pending.State.ATTEMPTED;
|
}
|
||||||
try {
|
}
|
||||||
agentsFor(target).send(target, p.text());
|
if (t.inboxOffer == null && modServed) {
|
||||||
p.state = Pending.State.DELIVERED;
|
t.inboxOffer = paneInbox.offer(target, p.text());
|
||||||
t.awaitingPickup = true;
|
}
|
||||||
t.awaitingCompletion = true;
|
if (t.inboxOffer != null) {
|
||||||
t.turnObserved = false;
|
// The message stays at the head of the queue until the pane takes
|
||||||
t.injectableSincePickup = 0;
|
// it: nothing has reached the pane yet, so nothing may be recorded
|
||||||
sent = p;
|
// as delivered and nothing may be failed.
|
||||||
} catch (Throwable e) {
|
if (t.inboxOffer.taken()) {
|
||||||
// fleetd #551: leave p.state == ATTEMPTED (recorded above, before the
|
t.inboxOffer = null;
|
||||||
// call) rather than downgrading it to NOT_DELIVERED here — reaching this
|
t.queue.poll();
|
||||||
// catch does not prove the text never reached the pane. Three of the
|
p.state = Pending.State.DELIVERED;
|
||||||
// four HerdrException throw sites in HerdrCodec fire only after herdr
|
t.awaitingPickup = true;
|
||||||
// has already replied (so it processed the request), and the fourth (a
|
t.awaitingCompletion = true;
|
||||||
// transport IOException) leaves it genuinely unknown whether herdr even
|
t.turnObserved = false;
|
||||||
// received the bytes — see #551 comment 16867. The one exception is a
|
t.injectableSincePickup = 0;
|
||||||
// herdr `*_not_found` error: that family is already read as "definitely
|
t.deliveredViaInbox = true;
|
||||||
// absent, not merely inconclusive" everywhere else in this codebase
|
sent = p;
|
||||||
// (StatusPoller, AgentControl's own retry, WorkspaceControl,
|
}
|
||||||
// HerdrPeerLauncher, FleetApp, ReplyPushLoop) because it means the
|
} else {
|
||||||
// target pane/agent does not exist at all, so nothing could have been
|
// fleetd #551: poll and record BEFORE the irreversible send, not after.
|
||||||
// pasted anywhere — #551 keeps the new state consistent with that
|
// The entry comes off the queue and its state is set to ATTEMPTED here,
|
||||||
// existing vocabulary rather than inventing a second one.
|
// unconditionally — so a Throwable escaping the send call below (caught or
|
||||||
if (e instanceof HerdrException he && he.code() != null
|
// not) can never leave the entry QUEUED at the head of t.queue (the fleetd
|
||||||
&& he.code().endsWith("_not_found")) {
|
// #546 hazard, since peek() alone would let the next onStatus round re-enter
|
||||||
p.state = Pending.State.NOT_DELIVERED;
|
// 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) {
|
} else if (p != null) {
|
||||||
// fleetd #501: stamp the wall-clock time of the FIRST non-ready sample in
|
// 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) {
|
for (Pending pending : notReady) {
|
||||||
pending.state = Pending.State.NOT_DELIVERED;
|
pending.state = Pending.State.NOT_DELIVERED;
|
||||||
}
|
}
|
||||||
|
paneInbox.withdrawAll(target);
|
||||||
|
t.inboxOffer = null;
|
||||||
// fleetd #501: t.notReadySincePoll — the loop's own counter, already in
|
// fleetd #501: t.notReadySincePoll — the loop's own counter, already in
|
||||||
// scope — is printed here instead of the READINESS_GRACE_POLLS constant.
|
// scope — is printed here instead of the READINESS_GRACE_POLLS constant.
|
||||||
// On this branch the counter has JUST reached the threshold, so the two
|
// 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
|
// 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.
|
// completion awaited), so the map cannot grow without bound across short-lived workers.
|
||||||
if (isQuiescent(t)) {
|
if (isQuiescent(t)) {
|
||||||
@@ -676,6 +782,19 @@ public final class Injector {
|
|||||||
forget.accept(target);
|
forget.accept(target);
|
||||||
turnListener.onTurnFailed(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 (turnCompleted) {
|
||||||
if (startPostTurn) {
|
if (startPostTurn) {
|
||||||
// fleetd #553: the listener call is wrapped so `t.postTurnPending` (set true inside
|
// 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
|
* 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).
|
* awaited turn completion (so the {@code working → idle} boundary is observed).
|
||||||
@@ -812,6 +949,23 @@ public final class Injector {
|
|||||||
.collect(Collectors.toSet());
|
.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
|
* 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
|
* 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;
|
p.state = Pending.State.NOT_DELIVERED;
|
||||||
}
|
}
|
||||||
t.queue.clear();
|
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;
|
hadDeliveredTurn = t.awaitingCompletion;
|
||||||
t.awaitingCompletion = false;
|
t.awaitingCompletion = false;
|
||||||
t.awaitingPickup = false;
|
t.awaitingPickup = false;
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks which workers are <em>available</em> — their Claude has booted and connected its MCP client
|
* Tracks which peers are <em>available</em> — their Claude has booted and connected its MCP
|
||||||
* to the bridge (CB-113). This is the reliable readiness signal, unlike herdr's {@code agent_status},
|
* client to the bridge. For a spawned member this is the reliable readiness signal,
|
||||||
* which reports {@code idle} for a worker whose Claude is still booting. Delivering into that boot
|
* unlike herdr's {@code agent_status}, which reports {@code idle} while its Claude is still
|
||||||
* window pastes into a not-yet-ready TUI (the text is lost) and wedges the worker's delivery state,
|
* booting. Delivering into that boot window pastes into a not-yet-ready TUI (the text is lost)
|
||||||
* so the {@link Injector} holds the first delivery until the worker is present here.
|
* 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
|
* <p>Populated from the MCP transport, for the peers whose deliverability rests on proving a live
|
||||||
* marks that worker present (its {@code initialize} is the first such contact). A worker that never
|
* MCP contact rather than on a configured registry entry. A peer that never mounts the bridge MCP
|
||||||
* mounts the bridge MCP is never marked present — its sends stay queued until they time out, which is
|
* is never marked present — its sends stay queued until they time out, which is correct (it could
|
||||||
* correct (it could not have replied anyway).
|
* not have replied anyway).
|
||||||
*/
|
*/
|
||||||
public class MemberPresence {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import dev.ltms.fleet.herdr.Agent;
|
|||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.HerdrException;
|
import dev.ltms.fleet.herdr.HerdrException;
|
||||||
import dev.ltms.fleet.herdr.PendingCloseMarker;
|
import dev.ltms.fleet.herdr.PendingCloseMarker;
|
||||||
|
import dev.ltms.fleet.herdr.ResilientAgentLaunch;
|
||||||
import dev.ltms.fleet.herdr.Tab;
|
import dev.ltms.fleet.herdr.Tab;
|
||||||
import dev.ltms.fleet.herdr.Workspace;
|
import dev.ltms.fleet.herdr.Workspace;
|
||||||
import dev.ltms.fleet.herdr.WorkspaceControl;
|
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||||
@@ -12,13 +13,16 @@ import dev.ltms.fleet.launch.ClaudeCodeArguments;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import dev.ltms.fleet.peer.PeerLauncher;
|
import dev.ltms.fleet.peer.PeerLauncher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,9 +84,19 @@ public final class LeadLauncher {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(LeadLauncher.class);
|
private static final Logger log = LoggerFactory.getLogger(LeadLauncher.class);
|
||||||
|
|
||||||
|
/** Attempts {@link #relaunch(String)} makes before giving up and returning {@code null}. */
|
||||||
|
static final int RELAUNCH_ATTEMPTS = 3;
|
||||||
|
|
||||||
private final AgentControl agents;
|
private final AgentControl agents;
|
||||||
private final WorkspaceControl spaces;
|
private final WorkspaceControl spaces;
|
||||||
private final FleetConfig cfg;
|
private final FleetConfig cfg;
|
||||||
|
private final Runnable sleeper;
|
||||||
|
|
||||||
|
// Per-process token mixed into each lead agent name so a fresh daemon process (seq back at 0)
|
||||||
|
// cannot collide with a same-name lead that outlived a restart — the same scheme
|
||||||
|
// HerdrPeerLauncher uses for members (fleetd #727).
|
||||||
|
private final String nameNonce = String.format("%06x", new SecureRandom().nextInt(1 << 24));
|
||||||
|
private final AtomicLong nameSeq = new AtomicLong();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param agents herdr agent control (start, list)
|
* @param agents herdr agent control (start, list)
|
||||||
@@ -90,9 +104,27 @@ public final class LeadLauncher {
|
|||||||
* @param cfg the loaded config — {@code fleet.leaders}, {@code profiles} and each lead's tab
|
* @param cfg the loaded config — {@code fleet.leaders}, {@code profiles} and each lead's tab
|
||||||
*/
|
*/
|
||||||
public LeadLauncher(AgentControl agents, WorkspaceControl spaces, FleetConfig cfg) {
|
public LeadLauncher(AgentControl agents, WorkspaceControl spaces, FleetConfig cfg) {
|
||||||
|
this(agents, spaces, cfg, () -> sleepUninterruptibly(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seam: as above, plus an injectable {@code sleeper} for the {@code agent_pane_busy}
|
||||||
|
* retry (fleetd #727), so a test can prove the retry budget without a real sleep.
|
||||||
|
*/
|
||||||
|
LeadLauncher(AgentControl agents, WorkspaceControl spaces, FleetConfig cfg, Runnable sleeper) {
|
||||||
this.agents = agents;
|
this.agents = agents;
|
||||||
this.spaces = spaces;
|
this.spaces = spaces;
|
||||||
this.cfg = cfg;
|
this.cfg = cfg;
|
||||||
|
this.sleeper = sleeper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uninterruptible sleep — the production {@link #sleeper} between {@code agent_pane_busy} retries. */
|
||||||
|
private static void sleepUninterruptibly(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,24 +205,14 @@ public final class LeadLauncher {
|
|||||||
log.info("lead '{}': {} live, {} wanted — nothing to start", name, running, wanted);
|
log.info("lead '{}': {} live, {} wanted — nothing to start", name, running, wanted);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!lead.isCreatable()) {
|
|
||||||
// A lead with a `tab:` but no `profile:` is recognise-only by design: the operator
|
|
||||||
// opens it by hand. Say so once rather than looking like a silent failure.
|
|
||||||
log.info("lead '{}' is not live, and names no profile — it can be recognised but not "
|
|
||||||
+ "launched. Add `profile:` under fleet.leaders.{} to have fleetd start it.",
|
|
||||||
name, name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
FleetConfig.Profile profile = cfg.profiles().get(lead.profile());
|
ResolvedLead resolved = resolveLaunchable(name);
|
||||||
if (profile == null) {
|
if (resolved == null) {
|
||||||
log.warn("lead '{}' names profile '{}', which is not configured — not launching",
|
|
||||||
name, lead.profile());
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = running; i < wanted; i++) {
|
for (int i = running; i < wanted; i++) {
|
||||||
if (launch(name, lead, profile)) {
|
if (launch(name, resolved.lead(), resolved.profile()) != null) {
|
||||||
started++;
|
started++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,17 +220,89 @@ public final class LeadLauncher {
|
|||||||
return started;
|
return started;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A declared lead paired with the profile it launches on — {@link #resolveLaunchable}'s result. */
|
||||||
|
private record ResolvedLead(FleetConfig.Leader lead, FleetConfig.Profile profile) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The declared {@code Leader} and its {@code Profile} for {@code name}, read from the config
|
||||||
|
* snapshot this launcher was constructed with.
|
||||||
|
*
|
||||||
|
* @return the resolved pair, or {@code null} (having logged) if {@code name} is not declared
|
||||||
|
* under {@code fleet.leaders}, that lead names no {@code profile:} (a {@code tab:}-only,
|
||||||
|
* recognise-only lead), or its {@code profile:} is not configured. Shared by
|
||||||
|
* {@link #ensureLeads()} and {@link #relaunch(String)} so the three refusals and their
|
||||||
|
* wording live in one place.
|
||||||
|
*/
|
||||||
|
private ResolvedLead resolveLaunchable(String name) {
|
||||||
|
FleetConfig.Leader lead = cfg.fleet().leaders().get(name);
|
||||||
|
if (lead == null) {
|
||||||
|
log.warn("lead '{}' is not declared under fleet.leaders — not launching", name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!lead.isCreatable()) {
|
||||||
|
// A lead with a `tab:` but no `profile:` is recognise-only by design: the operator
|
||||||
|
// opens it by hand. Say so once rather than looking like a silent failure.
|
||||||
|
log.info("lead '{}' names no profile — it can be recognised but not launched. Add "
|
||||||
|
+ "`profile:` under fleet.leaders.{} to have fleetd start it.", name, name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
FleetConfig.Profile profile = cfg.profiles().get(lead.profile());
|
||||||
|
if (profile == null) {
|
||||||
|
log.warn("lead '{}' names profile '{}', which is not configured — not launching",
|
||||||
|
name, lead.profile());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new ResolvedLead(lead, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the named lead from the config snapshot this launcher was constructed with — not a
|
||||||
|
* live read, so a lead's {@code profile:} or {@code tab:} edited in config needs a daemon
|
||||||
|
* restart to take effect here — outside of {@link #ensureLeads()}'s {@code instances}
|
||||||
|
* bookkeeping.
|
||||||
|
*
|
||||||
|
* @return the started {@link Agent}, or {@code null} if {@code name} is not declared under
|
||||||
|
* {@code fleet.leaders}, that lead names no {@code profile:} (a {@code tab:}-only,
|
||||||
|
* recognise-only lead), its {@code profile:} is not configured, or every attempt up to
|
||||||
|
* {@link #RELAUNCH_ATTEMPTS} failed to start it. Never throws.
|
||||||
|
*
|
||||||
|
* <p>Does not count how many instances of this lead are already live. {@link #ensureLeads()}'s
|
||||||
|
* count exists to avoid starting a second orchestrator; the caller of this method has already
|
||||||
|
* decided to replace the lead and owns that decision.
|
||||||
|
*
|
||||||
|
* <p>Retries the whole launch attempt — not only the {@code agent_name_taken}/
|
||||||
|
* {@code agent_pane_busy} cases {@link ResilientAgentLaunch} already retries inside one
|
||||||
|
* {@code agents.start} call — up to {@link #RELAUNCH_ATTEMPTS} times, sleeping via the
|
||||||
|
* injected sleeper between attempts, and returns the agent from the first attempt that
|
||||||
|
* succeeds.
|
||||||
|
*/
|
||||||
|
public Agent relaunch(String name) {
|
||||||
|
ResolvedLead resolved = resolveLaunchable(name);
|
||||||
|
if (resolved == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int attempt = 1; attempt <= RELAUNCH_ATTEMPTS; attempt++) {
|
||||||
|
Agent started = launch(name, resolved.lead(), resolved.profile());
|
||||||
|
if (started != null) {
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
if (attempt < RELAUNCH_ATTEMPTS) {
|
||||||
|
sleeper.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many live leads exist per configured name, and which of that name's labelled tabs are
|
* 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}
|
* <em>not</em> live: a running agent in a tab, in that lead's own space, carrying one of its
|
||||||
* (CB-579). A member sitting in the same shared workspace is not counted as a lead because its
|
* {@link FleetConfig.Leader#acceptedLabels()}. A member sitting in the same shared workspace is
|
||||||
* tab carries a different label, not because any workspace is excluded from this count.
|
* 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
|
||||||
* <p>There used to be a second path here — a running agent on the terminal a
|
* counted either — space is the uniqueness boundary between leads.
|
||||||
* {@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.
|
|
||||||
*
|
*
|
||||||
* <p>fleetd #359 review finding 1: a labelled tab with nothing running in it is split into
|
* <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
|
* {@code toClose} (already flagged pending-close by a previous reconcile, and still dead — two
|
||||||
@@ -222,12 +316,11 @@ public final class LeadLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, LeadCount> countLeads(Map<String, FleetConfig.Leader> leaders) {
|
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"
|
// A lead and the members share ONE workspace (the operator asked for a single "session" with
|
||||||
// with many tabs), so a workspace can no longer be excluded wholesale — the lead lives in the
|
// 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
|
// member workspace by design. The discriminator is the tab label together with the space: a
|
||||||
// its configured `fleet.leaders.<name>.tab` ("lead: opus"), while a member carries its
|
// member's tab never carries one of a lead's accepted labels, and a lead's own label only
|
||||||
// profile's `worker: {profile} #{n}` template. These never collide, so an exact-label match
|
// counts within that lead's configured space.
|
||||||
// separates them without needing to know which workspace anyone is in.
|
|
||||||
Map<String, String> nameByTab = new LinkedHashMap<>();
|
Map<String, String> nameByTab = new LinkedHashMap<>();
|
||||||
Set<String> flaggedTabIds = new LinkedHashSet<>();
|
Set<String> flaggedTabIds = new LinkedHashSet<>();
|
||||||
for (Workspace ws : spaces.listWorkspaces()) {
|
for (Workspace ws : spaces.listWorkspaces()) {
|
||||||
@@ -235,7 +328,7 @@ public final class LeadLauncher {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (Tab tab : spaces.listTabs(ws.workspaceId())) {
|
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) {
|
if (declared != null && tab.tabId() != null) {
|
||||||
nameByTab.put(tab.tabId(), declared);
|
nameByTab.put(tab.tabId(), declared);
|
||||||
if (PendingCloseMarker.isFlagged(tab.label())) {
|
if (PendingCloseMarker.isFlagged(tab.label())) {
|
||||||
@@ -285,29 +378,41 @@ 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
|
* <p>A match requires both: the label (case-insensitively, trailing {@link PendingCloseMarker}
|
||||||
* operator's {@code "lead: something-else"} tab is not mistaken for a configured lead. A
|
* stripped) must be one of the lead's {@link FleetConfig.Leader#acceptedLabels()}, and {@code
|
||||||
* trailing {@link PendingCloseMarker} is stripped first, so a tab this class flagged on a
|
* space} must be that lead's own {@link FleetConfig.Leader#workspace()}. The same label in a
|
||||||
* previous reconcile is still recognised as the same lead's tab on this one.
|
* different space names no lead — space is the uniqueness boundary between leads.
|
||||||
*/
|
*/
|
||||||
private String leadNameOf(String label, Map<String, FleetConfig.Leader> leaders) {
|
private String leadNameOf(String label, String space, Map<String, FleetConfig.Leader> leaders) {
|
||||||
if (label == null) {
|
if (label == null || space == null) {
|
||||||
return 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()) {
|
for (Map.Entry<String, FleetConfig.Leader> e : leaders.entrySet()) {
|
||||||
String tab = e.getValue().tabLabel();
|
FleetConfig.Leader lead = e.getValue();
|
||||||
if (tab != null && l.equalsIgnoreCase(tab.strip())) {
|
if (lead == null || !lead.workspace().equalsIgnoreCase(space)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lead.acceptedLabels().contains(l)) {
|
||||||
return e.getKey();
|
return e.getKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start one lead. Returns false (having logged) rather than throwing on any failure. */
|
/**
|
||||||
private boolean launch(String name, FleetConfig.Leader lead, FleetConfig.Profile profile) {
|
* Start one lead. Returns null (having logged) rather than throwing on any failure.
|
||||||
|
*
|
||||||
|
* <p>Goes through the same {@link ResilientAgentLaunch} seam every member spawn uses
|
||||||
|
* (fleetd #727): the assembled argv is refused outright if it cannot fit the pane line herdr
|
||||||
|
* types it into, a stale {@code agent_name_taken} (a crashed session's name the registry has
|
||||||
|
* not yet released) is retried under a fresh per-attempt name rather than refusing the whole
|
||||||
|
* relaunch, and a seed pane whose shell has not reached its prompt yet ({@code
|
||||||
|
* agent_pane_busy}) is retried rather than failing on the first miss.
|
||||||
|
*/
|
||||||
|
private Agent launch(String name, FleetConfig.Leader lead, FleetConfig.Profile profile) {
|
||||||
String label = lead.tabLabel();
|
String label = lead.tabLabel();
|
||||||
String cwd = (lead.cwd() == null || lead.cwd().isBlank())
|
String cwd = (lead.cwd() == null || lead.cwd().isBlank())
|
||||||
? System.getProperty("user.dir") : lead.cwd();
|
? System.getProperty("user.dir") : lead.cwd();
|
||||||
@@ -323,8 +428,12 @@ public final class LeadLauncher {
|
|||||||
// Same shape as the member launchers: herdr resolves the executable from `kind`, so
|
// Same shape as the member launchers: herdr resolves the executable from `kind`, so
|
||||||
// argv[0] (the configured launcher, e.g. `ccs`) is dropped and only the rest is passed.
|
// argv[0] (the configured launcher, e.g. `ccs`) is dropped and only the rest is passed.
|
||||||
List<String> argv = leadArgv(profile);
|
List<String> argv = leadArgv(profile);
|
||||||
Agent started = agents.start("lead-" + name, herdrKind(profile),
|
ResilientAgentLaunch.checkFits(profile.profile(), argv);
|
||||||
argv.isEmpty() ? argv : argv.subList(1, argv.size()), tab.rootPaneId());
|
List<String> args = argv.isEmpty() ? argv : argv.subList(1, argv.size());
|
||||||
|
Agent started = ResilientAgentLaunch.startUniquelyNamed(agents, herdrKind(profile), args,
|
||||||
|
tab.rootPaneId(),
|
||||||
|
attempt -> "lead-" + name + "-" + nameNonce + "-" + nameSeq.incrementAndGet(),
|
||||||
|
ResilientAgentLaunch.NAME_RETRIES, ResilientAgentLaunch.SHELL_READY_RETRIES, sleeper);
|
||||||
|
|
||||||
// Label AFTER the start succeeds. A label written before would survive a failed start
|
// Label AFTER the start succeeds. A label written before would survive a failed start
|
||||||
// and then read back as a live lead on the next boot, which is the exact staleness the
|
// and then read back as a live lead on the next boot, which is the exact staleness the
|
||||||
@@ -334,7 +443,7 @@ public final class LeadLauncher {
|
|||||||
log.info("lead '{}' launched: profile={} tab={} pane={} terminal={} label='{}' cwd={}",
|
log.info("lead '{}' launched: profile={} tab={} pane={} terminal={} label='{}' cwd={}",
|
||||||
name, profile.profile(), tab.tab().tabId(), started.paneId(),
|
name, profile.profile(), tab.tab().tabId(), started.paneId(),
|
||||||
started.terminalId(), label, cwd);
|
started.terminalId(), label, cwd);
|
||||||
return true;
|
return started;
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.warn("lead '{}' failed to launch on profile '{}': {}",
|
log.warn("lead '{}' failed to launch on profile '{}': {}",
|
||||||
name, profile.profile(), e.getMessage());
|
name, profile.profile(), e.getMessage());
|
||||||
@@ -346,7 +455,7 @@ public final class LeadLauncher {
|
|||||||
tab.tab().tabId(), cleanup.getMessage());
|
tab.tab().tabId(), cleanup.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
* 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)
|
* 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
|
* to a herdr agent pane (from herdr), yielding that pane's {@code terminal_id}. A connection that
|
||||||
* that maps to no worker pane — the primary, or an off-host client — resolves to {@code null}.
|
* 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
|
* <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.
|
* herdr owns the PID→pane mapping, so a caller cannot claim to be at another pane. Single-host
|
||||||
* Single-host only (the herd shares the {@code fleetd} host); the token path is the split-host
|
* only (the herd shares the {@code fleetd} host); the token path is the split-host fallback.
|
||||||
* fallback.
|
|
||||||
*/
|
*/
|
||||||
public final class ConnectionIdentity {
|
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
|
* The caller resolved from the connection: the {@code terminal} of the pane it connects from
|
||||||
* primary / an off-host client), its {@code pid} (or {@code -1} if not resolvable), and whether
|
* (or {@code null} when the connection maps to no pane), its {@code pid} (or {@code -1} if not
|
||||||
* the pane scan behind {@code terminal} ran to completion ({@link #scanComplete}).
|
* resolvable), and whether the pane scan behind {@code terminal} ran to completion
|
||||||
|
* ({@link #scanComplete}).
|
||||||
*/
|
*/
|
||||||
public record Caller(String terminal, long pid, boolean 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
|
* The terminal id of the pane the caller connects from, or {@code null} if the connection
|
||||||
* on-host worker (treat as the primary).
|
* maps to no pane.
|
||||||
*/
|
*/
|
||||||
public String callerTerminal(String remoteAddr, int remotePort) {
|
public String callerTerminal(String remoteAddr, int remotePort) {
|
||||||
return resolve(remoteAddr, remotePort).terminal();
|
return resolve(remoteAddr, remotePort).terminal();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,8 @@ public enum FleetTool {
|
|||||||
STOP("fleet_stop"),
|
STOP("fleet_stop"),
|
||||||
PROFILES("fleet_profiles"),
|
PROFILES("fleet_profiles"),
|
||||||
WHOAMI("fleet_whoami"),
|
WHOAMI("fleet_whoami"),
|
||||||
HANDOVER("fleet_handover");
|
HANDOVER("fleet_handover"),
|
||||||
|
INBOX("fleet_inbox");
|
||||||
|
|
||||||
private final String wireName;
|
private final String wireName;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-slot, thread-safe registry for the primary's herdr {@code terminal_id}.
|
* Single-slot, thread-safe registry for the primary's herdr {@code terminal_id}.
|
||||||
@@ -18,13 +19,25 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
* <p>The push loop ({@code ReplyPushLoop}) uses {@link #isKnown()} to decide
|
* <p>The push loop ({@code ReplyPushLoop}) uses {@link #isKnown()} to decide
|
||||||
* whether active nudging is possible; an empty registry means the primary is
|
* whether active nudging is possible; an empty registry means the primary is
|
||||||
* off-host or non-herdr and delivery falls back to pull.
|
* off-host or non-herdr and delivery falls back to pull.
|
||||||
|
*
|
||||||
|
* <p><strong>A learned terminal can go stale; a configured lead's name cannot.</strong> A lead that
|
||||||
|
* is rolled (a fresh pane replacing the old one) keeps its name but gets a new {@code terminal_id}.
|
||||||
|
* So every terminal this class learns — the singleton and each per-target delegation — is recorded
|
||||||
|
* together with the delegating lead's name, when the caller carries one. {@link
|
||||||
|
* #currentPrimaryTerminal()} and {@link #nudgeTargetFor(String)} resolve that name back to a
|
||||||
|
* terminal through the live {@code currentTerminalForName} lookup before falling back to the
|
||||||
|
* terminal that was actually recorded. A caller with no name (an unnamed primary, an architect, a
|
||||||
|
* collaborator — none of those are leads a lookup keyed on lead names can resolve) is tracked by
|
||||||
|
* terminal alone, exactly as before this indirection existed.
|
||||||
*/
|
*/
|
||||||
public final class PrimaryRegistry {
|
public final class PrimaryRegistry {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(PrimaryRegistry.class);
|
private static final Logger log = LoggerFactory.getLogger(PrimaryRegistry.class);
|
||||||
|
|
||||||
private final AtomicReference<String> terminal = new AtomicReference<>();
|
private final AtomicReference<String> terminal = new AtomicReference<>();
|
||||||
|
private final AtomicReference<String> primaryName = new AtomicReference<>();
|
||||||
private final boolean pinned;
|
private final boolean pinned;
|
||||||
|
private final Function<String, String> currentTerminalForName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CB-532: worker terminal → the lead that delegated to it. The single slot above answers "who is
|
* CB-532: worker terminal → the lead that delegated to it. The single slot above answers "who is
|
||||||
@@ -33,12 +46,32 @@ public final class PrimaryRegistry {
|
|||||||
* other lead's delegations. This map answers the question that actually matters — "who is
|
* other lead's delegations. This map answers the question that actually matters — "who is
|
||||||
* waiting on THIS worker" — and is what lets {@code primary.terminal} be retired.
|
* waiting on THIS worker" — and is what lets {@code primary.terminal} be retired.
|
||||||
*/
|
*/
|
||||||
private final ConcurrentHashMap<String, String> leadByTarget = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Delegation> leadByTarget = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** A recorded delegator: the terminal learned from call traffic, and its name, if it has one. */
|
||||||
|
private record Delegation(String terminal, String name) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param pinnedTerminal an optional pinned terminal from config ({@code null}/blank = unpinned)
|
* @param pinnedTerminal an optional pinned terminal from config ({@code null}/blank = unpinned)
|
||||||
*/
|
*/
|
||||||
public PrimaryRegistry(String pinnedTerminal) {
|
public PrimaryRegistry(String pinnedTerminal) {
|
||||||
|
this(pinnedTerminal, name -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, with a live {@code lead name → current terminal} lookup — normally the inverse of
|
||||||
|
* the same {@code terminal_id → lead name} supplier {@code CallerResolver} and the lead-tab
|
||||||
|
* scan already read. A lookup that cannot place a name (it is not a currently recognised lead,
|
||||||
|
* or no lookup is wired) returns {@code null}, and every resolution here falls back to the
|
||||||
|
* terminal that was actually recorded.
|
||||||
|
*
|
||||||
|
* @param pinnedTerminal an optional pinned terminal from config ({@code null}/blank =
|
||||||
|
* unpinned)
|
||||||
|
* @param currentTerminalForName lead name → its current terminal, or {@code null} if that name
|
||||||
|
* is not a currently recognised lead
|
||||||
|
*/
|
||||||
|
public PrimaryRegistry(String pinnedTerminal, Function<String, String> currentTerminalForName) {
|
||||||
if (pinnedTerminal != null && !pinnedTerminal.isBlank()) {
|
if (pinnedTerminal != null && !pinnedTerminal.isBlank()) {
|
||||||
this.terminal.set(pinnedTerminal);
|
this.terminal.set(pinnedTerminal);
|
||||||
this.pinned = true;
|
this.pinned = true;
|
||||||
@@ -46,19 +79,31 @@ public final class PrimaryRegistry {
|
|||||||
} else {
|
} else {
|
||||||
this.pinned = false;
|
this.pinned = false;
|
||||||
}
|
}
|
||||||
|
this.currentTerminalForName = currentTerminalForName != null ? currentTerminalForName : name -> null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a terminal_id. No-op when:
|
* Record a terminal_id, with no lead name. No-op when:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>the registry is pinned (config override),
|
* <li>the registry is pinned (config override),
|
||||||
* <li>{@code terminalId} is {@code null} or blank (non-herdr caller).
|
* <li>{@code terminalId} is {@code null} or blank (non-herdr caller).
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public void record(String terminalId) {
|
public void record(String terminalId) {
|
||||||
|
record(terminalId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #record(String)}, additionally recording the caller's name — present for a
|
||||||
|
* configured lead, {@code null} for an unnamed primary. The name is what lets {@link
|
||||||
|
* #currentPrimaryTerminal()} keep nudging the same lead across a roll even though its terminal
|
||||||
|
* changed.
|
||||||
|
*/
|
||||||
|
public void record(String terminalId, String name) {
|
||||||
if (pinned) return;
|
if (pinned) return;
|
||||||
if (terminalId == null || terminalId.isBlank()) return;
|
if (terminalId == null || terminalId.isBlank()) return;
|
||||||
String prev = terminal.getAndSet(terminalId);
|
String prev = terminal.getAndSet(terminalId);
|
||||||
|
primaryName.set(blankToNull(name));
|
||||||
if (prev == null) {
|
if (prev == null) {
|
||||||
log.debug("primary terminal learned: {}", terminalId);
|
log.debug("primary terminal learned: {}", terminalId);
|
||||||
} else if (!prev.equals(terminalId)) {
|
} else if (!prev.equals(terminalId)) {
|
||||||
@@ -67,7 +112,8 @@ public final class PrimaryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record that {@code leadTerminal} owns the accepted delegation of worker {@code target} (CB-532).
|
* Record that {@code leadTerminal} owns the accepted delegation of worker {@code target}
|
||||||
|
* (CB-532), with no lead name.
|
||||||
*
|
*
|
||||||
* <p>Called from the {@code MessageService} accepted-delivery hook — only after a send has won
|
* <p>Called from the {@code MessageService} accepted-delivery hook — only after a send has won
|
||||||
* the session's send lock and queued delivery — where both halves are known (CB-548). It is
|
* the session's send lock and queued delivery — where both halves are known (CB-548). It is
|
||||||
@@ -77,10 +123,19 @@ public final class PrimaryRegistry {
|
|||||||
* lead that most recently delegated to it, which is the one waiting.
|
* lead that most recently delegated to it, which is the one waiting.
|
||||||
*/
|
*/
|
||||||
public void recordDelegation(String target, String leadTerminal) {
|
public void recordDelegation(String target, String leadTerminal) {
|
||||||
|
recordDelegation(target, leadTerminal, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #recordDelegation(String, String)}, additionally recording the delegating lead's
|
||||||
|
* name when the caller carries one. See {@link #record(String, String)} for why the name
|
||||||
|
* matters.
|
||||||
|
*/
|
||||||
|
public void recordDelegation(String target, String leadTerminal, String leadName) {
|
||||||
if (target == null || target.isBlank() || leadTerminal == null || leadTerminal.isBlank()) {
|
if (target == null || target.isBlank() || leadTerminal == null || leadTerminal.isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
leadByTarget.put(target, leadTerminal);
|
leadByTarget.put(target, new Delegation(leadTerminal, blankToNull(leadName)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forget a worker's delegating lead — call on release, so a torn-down session leaks nothing. */
|
/** Forget a worker's delegating lead — call on release, so a torn-down session leaks nothing. */
|
||||||
@@ -99,19 +154,59 @@ public final class PrimaryRegistry {
|
|||||||
* recorded delegation there is no right answer, so this returns empty rather than guessing —
|
* recorded delegation there is no right answer, so this returns empty rather than guessing —
|
||||||
* delivery degrades to pull, which is exactly what the durable inbox is for, instead of
|
* delivery degrades to pull, which is exactly what the durable inbox is for, instead of
|
||||||
* interrupting the wrong lead with someone else's result.
|
* interrupting the wrong lead with someone else's result.
|
||||||
|
*
|
||||||
|
* <p>A delegation recorded with a name is resolved to that lead's <em>current</em> terminal
|
||||||
|
* first — see {@link #currentTerminalForName} — so a lead that has since been rolled is still
|
||||||
|
* reachable here, not just the pane that delegated the work originally.
|
||||||
*/
|
*/
|
||||||
public Optional<String> nudgeTargetFor(String target) {
|
public Optional<String> nudgeTargetFor(String target) {
|
||||||
String lead = target == null ? null : leadByTarget.get(target);
|
Delegation delegation = target == null ? null : leadByTarget.get(target);
|
||||||
return lead != null ? Optional.of(lead) : Optional.ofNullable(terminal.get());
|
if (delegation != null) {
|
||||||
|
return Optional.of(resolveCurrent(delegation.terminal(), delegation.name()));
|
||||||
|
}
|
||||||
|
return currentPrimaryTerminal();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The known primary terminal, or empty if not yet learned (and not pinned). */
|
/**
|
||||||
|
* The known primary terminal, or empty if not yet learned (and not pinned) — the raw value as
|
||||||
|
* it was recorded, with no attempt to resolve a named lead's current pane. Callers that need a
|
||||||
|
* nudge destination which survives a lead roll want {@link #currentPrimaryTerminal()} instead.
|
||||||
|
*/
|
||||||
public Optional<String> primaryTerminal() {
|
public Optional<String> primaryTerminal() {
|
||||||
return Optional.ofNullable(terminal.get());
|
return Optional.ofNullable(terminal.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terminal to nudge for the singleton primary right now: the recorded name resolved to its
|
||||||
|
* current terminal when one was recorded and is still a recognised lead, otherwise the terminal
|
||||||
|
* that was actually recorded — empty only when nothing has been learned or pinned at all.
|
||||||
|
*/
|
||||||
|
public Optional<String> currentPrimaryTerminal() {
|
||||||
|
String learned = terminal.get();
|
||||||
|
if (learned == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(resolveCurrent(learned, primaryName.get()));
|
||||||
|
}
|
||||||
|
|
||||||
/** {@code true} once a terminal has been recorded (or was pinned at construction). */
|
/** {@code true} once a terminal has been recorded (or was pinned at construction). */
|
||||||
public boolean isKnown() {
|
public boolean isKnown() {
|
||||||
return terminal.get() != null;
|
return terminal.get() != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code learnedTerminal}, unless {@code name} is non-null and {@code currentTerminalForName}
|
||||||
|
* currently places that name at a different, live terminal — in which case the live one wins.
|
||||||
|
*/
|
||||||
|
private String resolveCurrent(String learnedTerminal, String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return learnedTerminal;
|
||||||
|
}
|
||||||
|
String current = currentTerminalForName.apply(name);
|
||||||
|
return current != null && !current.isBlank() ? current : learnedTerminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return s == null || s.isBlank() ? null : s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,27 +445,6 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
|||||||
+ " distinct candidate(s): " + String.join(", ", unreachable));
|
+ " distinct candidate(s): " + String.join(", ", unreachable));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refuse an explicit-profile spawn when the profile is at its {@code maxLoad} cap.
|
|
||||||
*
|
|
||||||
* <p>maxLoad is a documented, unconditional capacity limit (see {@code FleetConfig.Profile#maxLoad}),
|
|
||||||
* and the charter makes explicit-profile spawns the normal path — so enforcing it only in placement
|
|
||||||
* ({@code PlacementPolicyUtil}, package-private, hence not linked) would leave the cap dead config
|
|
||||||
* on every call that names a profile. Same rule as placement: {@code live >= cap} is at capacity.
|
|
||||||
*
|
|
||||||
* <p>Deliberately no fallback to another profile: the caller named {@code profile} for a cost/model
|
|
||||||
* reason, and silently re-routing a paid-tier (subscription) request elsewhere is worse than
|
|
||||||
* refusing it. A caller that wants placement should omit the profile and let the policy pick.
|
|
||||||
*
|
|
||||||
* <p>Known TOCTOU limitation — documented, not fixed. {@link #liveCount} is read outside any lock and
|
|
||||||
* {@code SessionManager} registers a session only after {@code launcher.spawn} returns, so two
|
|
||||||
* genuinely concurrent spawns can both pass this check. The race already exists on the placement
|
|
||||||
* path. Closing it needs slot reservation in the registry; serializing spawn here would block on
|
|
||||||
* the readiness gate and is a far worse trade.
|
|
||||||
*
|
|
||||||
* @param profile the profile the caller explicitly named
|
|
||||||
* @throws PlacementException when the profile is at capacity
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Refuse an explicit-profile spawn whose credential is quarantined (CB-578 stage B): a prior
|
* Refuse an explicit-profile spawn whose credential is quarantined (CB-578 stage B): a prior
|
||||||
* {@code BACKEND_EXHAUSTED} classification on this profile, or on another profile sharing its
|
* {@code BACKEND_EXHAUSTED} classification on this profile, or on another profile sharing its
|
||||||
@@ -527,6 +506,27 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
|||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse an explicit-profile spawn when the profile is at its {@code maxLoad} cap.
|
||||||
|
*
|
||||||
|
* <p>maxLoad is a documented, unconditional capacity limit (see {@code FleetConfig.Profile#maxLoad}),
|
||||||
|
* and the charter makes explicit-profile spawns the normal path — so enforcing it only in placement
|
||||||
|
* ({@code PlacementPolicyUtil}, package-private, hence not linked) would leave the cap dead config
|
||||||
|
* on every call that names a profile. Same rule as placement: {@code live >= cap} is at capacity.
|
||||||
|
*
|
||||||
|
* <p>No fallback to another profile: the caller named {@code profile} for a cost/model
|
||||||
|
* reason, and silently re-routing a paid-tier (subscription) request elsewhere is worse than
|
||||||
|
* refusing it. A caller that wants placement should omit the profile and let the policy pick.
|
||||||
|
*
|
||||||
|
* <p>Known TOCTOU limitation — documented, not fixed. {@link #liveCount} is read outside any lock and
|
||||||
|
* {@code SessionManager} registers a session only after {@code launcher.spawn} returns, so two
|
||||||
|
* genuinely concurrent spawns can both pass this check. The race already exists on the placement
|
||||||
|
* path. Closing it needs slot reservation in the registry; serializing spawn here would block on
|
||||||
|
* the readiness gate and is a far worse trade.
|
||||||
|
*
|
||||||
|
* @param profile the profile the caller explicitly named
|
||||||
|
* @throws PlacementException when the profile is at capacity
|
||||||
|
*/
|
||||||
private void enforceMaxLoad(String profile) {
|
private void enforceMaxLoad(String profile) {
|
||||||
// Absent config, or a config whose maxLoad normalized to null (ABSENT ⇒ unlimited at load),
|
// Absent config, or a config whose maxLoad normalized to null (ABSENT ⇒ unlimited at load),
|
||||||
// means no cap — never cap what wasn't configured. Note "non-positive ⇒ unlimited" was true
|
// means no cap — never cap what wasn't configured. Note "non-positive ⇒ unlimited" was true
|
||||||
|
|||||||
@@ -474,7 +474,6 @@ public final class EnvAllowListScrub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort recursive delete; failures are swallowed — JVM-exit cleanup is the backstop. */
|
|
||||||
/**
|
/**
|
||||||
* Remove generated directories left behind by an earlier daemon process.
|
* Remove generated directories left behind by an earlier daemon process.
|
||||||
*
|
*
|
||||||
@@ -511,6 +510,7 @@ public final class EnvAllowListScrub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best-effort recursive delete; failures are swallowed — JVM-exit cleanup is the backstop. */
|
||||||
static void deleteRecursively(Path dir) {
|
static void deleteRecursively(Path dir) {
|
||||||
if (dir == null || !Files.exists(dir)) {
|
if (dir == null || !Files.exists(dir)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dev.ltms.fleet.herdr.AgentControl;
|
|||||||
import dev.ltms.fleet.herdr.AgentStatus;
|
import dev.ltms.fleet.herdr.AgentStatus;
|
||||||
import dev.ltms.fleet.herdr.HerdrClient;
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
import dev.ltms.fleet.herdr.HerdrException;
|
import dev.ltms.fleet.herdr.HerdrException;
|
||||||
|
import dev.ltms.fleet.herdr.ResilientAgentLaunch;
|
||||||
import dev.ltms.fleet.herdr.Tab;
|
import dev.ltms.fleet.herdr.Tab;
|
||||||
import dev.ltms.fleet.herdr.Workspace;
|
import dev.ltms.fleet.herdr.Workspace;
|
||||||
import dev.ltms.fleet.herdr.WorkspaceControl;
|
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||||
@@ -67,16 +68,6 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(HerdrPeerLauncher.class);
|
private static final Logger log = LoggerFactory.getLogger(HerdrPeerLauncher.class);
|
||||||
|
|
||||||
/** herdr rejects a duplicate agent {@code name}; we retry a bumped name this many times. */
|
|
||||||
private static final int NAME_RETRIES = 8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retries for {@code agent.start} against a seed pane whose shell has not reached its prompt
|
|
||||||
* yet — {@code tab.create}/{@code pane.split} return as soon as the pane exists, and herdr
|
|
||||||
* refuses to start an agent in a pane that is not "an available shell" ({@code agent_pane_busy}).
|
|
||||||
*/
|
|
||||||
private static final int SHELL_READY_RETRIES = 20;
|
|
||||||
|
|
||||||
private final String namePrefix; // label prefix: naming + reap scheme
|
private final String namePrefix; // label prefix: naming + reap scheme
|
||||||
private final AgentControl agents;
|
private final AgentControl agents;
|
||||||
private final WorkspaceControl spaces;
|
private final WorkspaceControl spaces;
|
||||||
@@ -766,90 +757,26 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
|||||||
// Protocol 19 resolves the executable from the agent kind (== namePrefix here), so
|
// Protocol 19 resolves the executable from the agent kind (== namePrefix here), so
|
||||||
// argv[0] — the configured executable — is dropped and only the extra args are passed.
|
// argv[0] — the configured executable — is dropped and only the extra args are passed.
|
||||||
List<String> args = argv.isEmpty() ? argv : argv.subList(1, argv.size());
|
List<String> args = argv.isEmpty() ? argv : argv.subList(1, argv.size());
|
||||||
checkPaneCommandFits(cfg, argv);
|
try {
|
||||||
HerdrException last = null;
|
ResilientAgentLaunch.checkFits(cfg.profile(), argv);
|
||||||
for (int attempt = 0; attempt < NAME_RETRIES; attempt++) {
|
} catch (ResilientAgentLaunch.TooLargeException e) {
|
||||||
long seq = nameSeq.incrementAndGet();
|
throw new PeerUnreachableException(e.getMessage());
|
||||||
String name = namePrefix + "-" + cfg.profile() + "-" + nameNonce + "-" + seq;
|
|
||||||
try {
|
|
||||||
return new Started(startAwaitingShellPrompt(name, args, paneId), seq);
|
|
||||||
} catch (HerdrException e) {
|
|
||||||
if (!"agent_name_taken".equals(e.code())) throw e;
|
|
||||||
log.debug("peer name '{}' taken, retrying", name);
|
|
||||||
last = e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw last;
|
long[] lastSeq = {0};
|
||||||
}
|
Agent agent = ResilientAgentLaunch.startUniquelyNamed(agents, namePrefix, args, paneId,
|
||||||
|
attempt -> {
|
||||||
/**
|
lastSeq[0] = nameSeq.incrementAndGet();
|
||||||
* fleetd #220: herdr does not exec the launch command — it TYPES it into the pane as one line,
|
return namePrefix + "-" + cfg.profile() + "-" + nameNonce + "-" + lastSeq[0];
|
||||||
* and a pty line buffer holds only {@value #PANE_COMMAND_BYTE_LIMIT} bytes (BSD/macOS {@code
|
},
|
||||||
* MAX_CANON}). Everything past that byte is dropped. Nothing reports it: herdr answers "agent
|
ResilientAgentLaunch.NAME_RETRIES, ResilientAgentLaunch.SHELL_READY_RETRIES, sleeper);
|
||||||
* started", the backend exits on the mangled argument it was handed, the pane closes, and the
|
return new Started(agent, lastSeq[0]);
|
||||||
* only symptom is {@link #waitUntilInjectableOrThrow} timing out 20 seconds later with no
|
|
||||||
* reason. That is exactly how #214 broke every claude-code spawn — one 50-byte flag pushed a
|
|
||||||
* 978-byte command to 1028, and the tail that got cut was {@code --autocompact 250000}.
|
|
||||||
*
|
|
||||||
* <p>So measure it here and refuse, loudly and immediately, rather than spawn something that
|
|
||||||
* cannot work. The estimate is deliberately conservative: fleetd cannot see herdr's quoting, so
|
|
||||||
* every argument is charged its own bytes plus a separator and a quote pair. An over-estimate
|
|
||||||
* costs a clear error at a length that was already unsafe; an under-estimate would let the
|
|
||||||
* silent truncation back in.
|
|
||||||
*
|
|
||||||
* @throws PeerUnreachableException when the command cannot fit — the same failure the spawn
|
|
||||||
* would have hit anyway, named at the point it is still
|
|
||||||
* explainable
|
|
||||||
*/
|
|
||||||
private void checkPaneCommandFits(FleetConfig.Profile cfg, List<String> argv) {
|
|
||||||
int bytes = 0;
|
|
||||||
String longest = null;
|
|
||||||
int longestBytes = 0;
|
|
||||||
for (String arg : argv) {
|
|
||||||
int argBytes = arg == null ? 0 : arg.getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
|
|
||||||
bytes += argBytes + QUOTING_OVERHEAD_PER_ARG;
|
|
||||||
if (argBytes > longestBytes) {
|
|
||||||
longestBytes = argBytes;
|
|
||||||
longest = arg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bytes <= PANE_COMMAND_BYTE_LIMIT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String culprit = longest == null ? "<none>"
|
|
||||||
: longest.substring(0, Math.min(longest.length(), 60)) + (longest.length() > 60 ? "…" : "");
|
|
||||||
throw new PeerUnreachableException(
|
|
||||||
"launch command for profile " + cfg.profile() + " is about " + bytes + " bytes, over the "
|
|
||||||
+ PANE_COMMAND_BYTE_LIMIT + "-byte limit of the pane line herdr types it into. "
|
|
||||||
+ "The pty would drop the tail silently and the backend would exit on a mangled "
|
|
||||||
+ "argument. Longest argument is " + longestBytes + " bytes: " + culprit
|
|
||||||
+ " — move it off the command line (a file flag) or shorten it.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pty line buffer herdr types a launch command into: BSD/macOS {@code MAX_CANON}. Not a
|
* The pty line buffer herdr types a launch command into: BSD/macOS {@code MAX_CANON}. Not a
|
||||||
* fleetd choice and not configurable — see {@link #checkPaneCommandFits}.
|
* fleetd choice and not configurable — see {@link ResilientAgentLaunch#checkFits}.
|
||||||
*/
|
*/
|
||||||
static final int PANE_COMMAND_BYTE_LIMIT = 1024;
|
static final int PANE_COMMAND_BYTE_LIMIT = ResilientAgentLaunch.PANE_COMMAND_BYTE_LIMIT;
|
||||||
|
|
||||||
/** Per-argument allowance for the separating space and a shell quote pair fleetd cannot see. */
|
|
||||||
private static final int QUOTING_OVERHEAD_PER_ARG = 3;
|
|
||||||
|
|
||||||
/** Start the agent into {@code paneId}, waiting out the seed shell's boot with the sleeper. */
|
|
||||||
private Agent startAwaitingShellPrompt(String name, List<String> args, String paneId) {
|
|
||||||
HerdrException busy = null;
|
|
||||||
for (int attempt = 0; attempt < SHELL_READY_RETRIES; attempt++) {
|
|
||||||
try {
|
|
||||||
return agents.start(name, namePrefix, args, paneId);
|
|
||||||
} catch (HerdrException e) {
|
|
||||||
if (!"agent_pane_busy".equals(e.code())) throw e;
|
|
||||||
log.debug("pane {} not at its shell prompt yet, retrying agent.start", paneId);
|
|
||||||
busy = e;
|
|
||||||
sleeper.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw busy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- discovery + reap ----------------------------------------------------------------------
|
// --- discovery + reap ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.ltms.fleet.msg;
|
|||||||
|
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.AgentStatus;
|
import dev.ltms.fleet.herdr.AgentStatus;
|
||||||
|
import dev.ltms.fleet.herdr.PromptBox;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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
|
* <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
|
* 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
|
* 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
|
* <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
|
* 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 LeadChannel channel;
|
||||||
private final AgentControl agents;
|
private final AgentControl agents;
|
||||||
|
private final PromptBox promptBox;
|
||||||
private final Supplier<Map<String, String>> leads;
|
private final Supplier<Map<String, String>> leads;
|
||||||
private final ScheduledExecutorService scheduler;
|
private final ScheduledExecutorService scheduler;
|
||||||
private final long intervalMs;
|
private final long intervalMs;
|
||||||
@@ -73,6 +76,7 @@ public final class LeadCoordLoop {
|
|||||||
ScheduledExecutorService scheduler, long intervalMs) {
|
ScheduledExecutorService scheduler, long intervalMs) {
|
||||||
this.channel = channel;
|
this.channel = channel;
|
||||||
this.agents = agents;
|
this.agents = agents;
|
||||||
|
this.promptBox = new PromptBox(agents);
|
||||||
this.leads = leads;
|
this.leads = leads;
|
||||||
this.scheduler = scheduler;
|
this.scheduler = scheduler;
|
||||||
this.intervalMs = intervalMs;
|
this.intervalMs = intervalMs;
|
||||||
@@ -149,6 +153,11 @@ public final class LeadCoordLoop {
|
|||||||
lead, status, held.size());
|
lead, status, held.size());
|
||||||
return;
|
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 {
|
try {
|
||||||
agents.send(lead, DELIVERY_FORMAT.formatted(msg.from(), msg.content()));
|
agents.send(lead, DELIVERY_FORMAT.formatted(msg.from(), msg.content()));
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.ltms.fleet.msg;
|
|||||||
|
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.AgentStatus;
|
import dev.ltms.fleet.herdr.AgentStatus;
|
||||||
|
import dev.ltms.fleet.herdr.PromptBox;
|
||||||
import dev.ltms.fleet.lead.LeadContextGauge;
|
import dev.ltms.fleet.lead.LeadContextGauge;
|
||||||
import dev.ltms.fleet.mcp.PrimaryRegistry;
|
import dev.ltms.fleet.mcp.PrimaryRegistry;
|
||||||
import dev.ltms.fleet.metrics.FleetMetrics;
|
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
|
* 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).
|
* 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>
|
* <ol>
|
||||||
* <li><b>Status-gated</b> — a {@code WORKING} lead is making progress and is never touched; only an
|
* <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>
|
* 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
|
* <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
|
* loop stands down, so two competing injections never start two turns in the same pane
|
||||||
* (constraint 6).</li>
|
* (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>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <p><b>fleetd #609 — context-high notice.</b> Optionally ({@code contextHighNudge}, opt-in like the
|
* <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 PrimaryRegistry primaryRegistry;
|
||||||
private final AgentControl agents;
|
private final AgentControl agents;
|
||||||
|
private final PromptBox promptBox;
|
||||||
private final ReplyInbox inbox;
|
private final ReplyInbox inbox;
|
||||||
private final Supplier<List<MemberSession>> roster;
|
private final Supplier<List<MemberSession>> roster;
|
||||||
private final ReplyPushLoop pushLoop;
|
private final ReplyPushLoop pushLoop;
|
||||||
@@ -135,6 +139,7 @@ public final class LeadHeartbeatLoop {
|
|||||||
boolean requireOperatorConfirm) {
|
boolean requireOperatorConfirm) {
|
||||||
this.primaryRegistry = primaryRegistry;
|
this.primaryRegistry = primaryRegistry;
|
||||||
this.agents = agents;
|
this.agents = agents;
|
||||||
|
this.promptBox = new PromptBox(agents);
|
||||||
this.inbox = inbox;
|
this.inbox = inbox;
|
||||||
this.roster = roster;
|
this.roster = roster;
|
||||||
this.pushLoop = pushLoop;
|
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. */
|
/** Idle past the quiet period with nothing pending and the cap exhausted — stop until new state appears. */
|
||||||
QUIET_DONE,
|
QUIET_DONE,
|
||||||
/** {@link ReplyPushLoop} is actively nudging — stand aside rather than start a competing turn. */
|
/** {@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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,12 +314,12 @@ public final class LeadHeartbeatLoop {
|
|||||||
* (mirroring {@link ReplyPushLoop#tick(String)}) so tests can drive it directly with a fake clock and a
|
* (mirroring {@link ReplyPushLoop#tick(String)}) so tests can drive it directly with a fake clock and a
|
||||||
* fake {@link AgentControl} instead of racing the scheduler thread. */
|
* fake {@link AgentControl} instead of racing the scheduler thread. */
|
||||||
void tick() {
|
void tick() {
|
||||||
boolean leadKnown = primaryRegistry.primaryTerminal().isPresent();
|
boolean leadKnown = primaryRegistry.currentPrimaryTerminal().isPresent();
|
||||||
FleetState fleet = snapshot(inbox, roster);
|
FleetState fleet = snapshot(inbox, roster);
|
||||||
AgentStatus status = AgentStatus.UNKNOWN;
|
AgentStatus status = AgentStatus.UNKNOWN;
|
||||||
LeadContextGauge.Reading reading = LeadContextGauge.Reading.unknown();
|
LeadContextGauge.Reading reading = LeadContextGauge.Reading.unknown();
|
||||||
if (leadKnown) {
|
if (leadKnown) {
|
||||||
String leadTerminal = primaryRegistry.primaryTerminal().orElseThrow();
|
String leadTerminal = primaryRegistry.currentPrimaryTerminal().orElseThrow();
|
||||||
try {
|
try {
|
||||||
status = agents.status(leadTerminal);
|
status = agents.status(leadTerminal);
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
@@ -330,6 +337,7 @@ public final class LeadHeartbeatLoop {
|
|||||||
idleSinceNanos == NOT_IDLE ? null : idleSinceNanos,
|
idleSinceNanos == NOT_IDLE ? null : idleSinceNanos,
|
||||||
quietCount, status, pushLoop.isActive(), leadKnown, fleet,
|
quietCount, status, pushLoop.isActive(), leadKnown, fleet,
|
||||||
reading.state(), contextNotified);
|
reading.state(), contextNotified);
|
||||||
|
d = holdIfOperatorIsTyping(d);
|
||||||
applyDecision(d);
|
applyDecision(d);
|
||||||
switch (d.action()) {
|
switch (d.action()) {
|
||||||
case INJECT -> injectNudge(d, fleet, reading);
|
case INJECT -> injectNudge(d, fleet, reading);
|
||||||
@@ -337,11 +345,33 @@ public final class LeadHeartbeatLoop {
|
|||||||
countNudge("exhausted");
|
countNudge("exhausted");
|
||||||
contextNotified = d.contextNotified();
|
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();
|
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.
|
* Persist the idle/quiet state a decision returned, so the next tick starts from it.
|
||||||
*
|
*
|
||||||
@@ -367,7 +397,7 @@ public final class LeadHeartbeatLoop {
|
|||||||
// itself should be built from. Otherwise a HIGH stretch that is still latched would never see the
|
// itself should be built from. Otherwise a HIGH stretch that is still latched would never see the
|
||||||
// notice at all, defeating the very check this fixes.
|
// notice at all, defeating the very check this fixes.
|
||||||
String notice = contextNotice(contextHighNudge, reading, contextNotified, requireOperatorConfirm);
|
String notice = contextNotice(contextHighNudge, reading, contextNotified, requireOperatorConfirm);
|
||||||
var lead = primaryRegistry.primaryTerminal();
|
var lead = primaryRegistry.currentPrimaryTerminal();
|
||||||
boolean sent = lead.isPresent() && trySend(lead.get(), fleet.nudgeText() + notice, notice);
|
boolean sent = lead.isPresent() && trySend(lead.get(), fleet.nudgeText() + notice, notice);
|
||||||
// The latch becomes true only when all three hold: decide() chose to notify, a notice was
|
// The latch becomes true only when all three hold: decide() chose to notify, a notice was
|
||||||
// actually included in the text, and the send reached the pane without throwing. Whenever no
|
// actually included in the text, and the send reached the pane without throwing. Whenever no
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.ltms.fleet.msg;
|
package dev.ltms.fleet.msg;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.auth.Principal;
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.AgentStatus;
|
import dev.ltms.fleet.herdr.AgentStatus;
|
||||||
import dev.ltms.fleet.herdr.HerdrRouter;
|
import dev.ltms.fleet.herdr.HerdrRouter;
|
||||||
@@ -11,6 +12,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
@@ -87,7 +89,7 @@ public final class MessageService {
|
|||||||
/**
|
/**
|
||||||
* The worker paused mid-turn to ask the primary a question (CB-205); {@code text} is the
|
* The worker paused mid-turn to ask the primary a question (CB-205); {@code text} is the
|
||||||
* question and {@code turnId} correlates the answer. Not terminal — the primary answers with
|
* question and {@code turnId} correlates the answer. Not terminal — the primary answers with
|
||||||
* {@link #answer(String, String, long)} and the turn resumes.
|
* {@link #answer(String, String, long, String)} and the turn resumes.
|
||||||
*/
|
*/
|
||||||
QUESTION,
|
QUESTION,
|
||||||
/** Timed out after the message was delivered — the worker is still working. */
|
/** Timed out after the message was delivered — the worker is still working. */
|
||||||
@@ -120,10 +122,16 @@ public final class MessageService {
|
|||||||
/** Another send to this session was in flight for the whole window. */
|
/** Another send to this session was in flight for the whole window. */
|
||||||
BUSY,
|
BUSY,
|
||||||
/**
|
/**
|
||||||
* An answer ({@link #answer(String, String, long)}) referenced a {@code turnId} that is no
|
* An answer ({@link #answer(String, String, long, String)}) referenced a {@code turnId}
|
||||||
* longer open — the worker's {@code fleet_ask} already timed out or was answered.
|
* that is no longer open — the worker's {@code fleet_ask} already timed out or was answered.
|
||||||
*/
|
*/
|
||||||
STALE_TURN
|
STALE_TURN,
|
||||||
|
/**
|
||||||
|
* An answer ({@link #answer(String, String, long, String)}) named a {@code turnId} that is
|
||||||
|
* still open, but the answering caller is not the caller whose accepted delegation opened
|
||||||
|
* it. Distinct from {@link #STALE_TURN} so a refusal is never reported as a lapsed turn.
|
||||||
|
*/
|
||||||
|
NOT_TURN_OWNER
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +141,7 @@ public final class MessageService {
|
|||||||
* {@link Outcome#COMPLETED_UNREPLIED}), or the question for {@link Outcome#QUESTION},
|
* {@link Outcome#COMPLETED_UNREPLIED}), or the question for {@link Outcome#QUESTION},
|
||||||
* else {@code null}
|
* else {@code null}
|
||||||
* @param turnId correlation id for a {@link Outcome#QUESTION} (answered via
|
* @param turnId correlation id for a {@link Outcome#QUESTION} (answered via
|
||||||
* {@link #answer(String, String, long)}), else {@code null}
|
* {@link #answer(String, String, long, String)}), else {@code null}
|
||||||
*/
|
*/
|
||||||
public record Reply(Outcome outcome, String text, String turnId) {
|
public record Reply(Outcome outcome, String text, String turnId) {
|
||||||
/** A reply with no correlation id (the common terminal outcomes). */
|
/** A reply with no correlation id (the common terminal outcomes). */
|
||||||
@@ -275,10 +283,16 @@ public final class MessageService {
|
|||||||
* {@link #abandon}) can never match again regardless of this flag's value.
|
* {@link #abandon}) can never match again regardless of this flag's value.
|
||||||
*/
|
*/
|
||||||
private volatile boolean askTimedOut;
|
private volatile boolean askTimedOut;
|
||||||
|
/**
|
||||||
|
* The owner key of the caller whose {@code fleet_send{wait:false}} created this ticket, or
|
||||||
|
* {@code null} for the unnamed primary and overloads that do not record a caller.
|
||||||
|
*/
|
||||||
|
private final String creatorOwner;
|
||||||
|
|
||||||
private Task(String ticket, String target, LongSupplier nowNanos) {
|
private Task(String ticket, String target, LongSupplier nowNanos, String creatorOwner) {
|
||||||
this.ticket = ticket;
|
this.ticket = ticket;
|
||||||
this.target = target;
|
this.target = target;
|
||||||
|
this.creatorOwner = creatorOwner;
|
||||||
this.createdNanos = nowNanos.getAsLong();
|
this.createdNanos = nowNanos.getAsLong();
|
||||||
future.whenComplete((reply, ex) -> completedNanos = nowNanos.getAsLong());
|
future.whenComplete((reply, ex) -> completedNanos = nowNanos.getAsLong());
|
||||||
}
|
}
|
||||||
@@ -340,6 +354,14 @@ public final class MessageService {
|
|||||||
*/
|
*/
|
||||||
private final ConcurrentHashMap<String, Boolean> queuedDeliveries = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Boolean> queuedDeliveries = new ConcurrentHashMap<>();
|
||||||
private final AtomicLong ticketSeq = new AtomicLong();
|
private final AtomicLong ticketSeq = new AtomicLong();
|
||||||
|
/**
|
||||||
|
* Minted once per {@code MessageService} instance and folded into every ticket id (see
|
||||||
|
* {@link #sendAsync(String, String, Runnable, Principal)}). {@link #ticketSeq} alone restarts at
|
||||||
|
* zero for every instance, so without this a ticket id can be reused across instances and
|
||||||
|
* resolve to an unrelated {@link Task} with no error; this nonce makes that impossible, because
|
||||||
|
* an id minted by one instance can never match the id space of another.
|
||||||
|
*/
|
||||||
|
private final String ticketBootNonce = UUID.randomUUID().toString().substring(0, 6);
|
||||||
private final ExecutorService asyncExecutor = Executors.newThreadPerTaskExecutor(
|
private final ExecutorService asyncExecutor = Executors.newThreadPerTaskExecutor(
|
||||||
Thread.ofVirtual().name("bridge-async-", 0).factory());
|
Thread.ofVirtual().name("bridge-async-", 0).factory());
|
||||||
|
|
||||||
@@ -492,6 +514,20 @@ public final class MessageService {
|
|||||||
return false;
|
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
|
* 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
|
* still parked waiting on this exact turn's answer, or — only once neither applies — queue it in
|
||||||
@@ -656,7 +692,7 @@ public final class MessageService {
|
|||||||
case TIMED_OUT_WORKING, TIMED_OUT_QUEUED, TIMED_OUT_UNCONFIRMED, BUSY -> "timeout";
|
case TIMED_OUT_WORKING, TIMED_OUT_QUEUED, TIMED_OUT_UNCONFIRMED, BUSY -> "timeout";
|
||||||
case WORKER_FAILED -> "failed";
|
case WORKER_FAILED -> "failed";
|
||||||
case BACKEND_EXHAUSTED -> "backend_exhausted";
|
case BACKEND_EXHAUSTED -> "backend_exhausted";
|
||||||
case STALE_TURN, QUESTION -> null; // not a completed delegation
|
case STALE_TURN, QUESTION, NOT_TURN_OWNER -> null; // not a completed delegation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,14 +951,17 @@ public final class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deliver {@code content} to {@code target} (a herdr {@code terminal_id}) and block until the
|
* Deliver {@code content} to {@code target} (a herdr {@code terminal_id}) and block until the
|
||||||
* worker replies via {@link Rendezvous} or {@code timeoutMillis} elapses.
|
* worker replies via {@link Rendezvous} or {@code timeoutMillis} elapses. {@code callerOwner}
|
||||||
|
* identifies the caller making this call and is recorded as the turn's owner. It is the only
|
||||||
|
* caller {@link #answer(String, String, long, String)} will
|
||||||
|
* later accept an answer from if the worker pauses mid-turn to ask.
|
||||||
*/
|
*/
|
||||||
public Reply send(String target, String content, long timeoutMillis) {
|
public Reply send(String target, String content, long timeoutMillis, String callerOwner) {
|
||||||
return send(target, content, timeoutMillis, null);
|
return send(target, content, timeoutMillis, null, callerOwner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* As {@link #send(String, String, long)}, but with an accepted-delivery hook.
|
* As {@link #send(String, String, long, String)}, but with an accepted-delivery hook.
|
||||||
*
|
*
|
||||||
* <p>{@code onAccepted} is invoked exactly once, once this send has won {@code target}'s send
|
* <p>{@code onAccepted} is invoked exactly once, once this send has won {@code target}'s send
|
||||||
* lock and so become the <em>accepted target turn</em> — it runs <em>before</em> delivery is
|
* lock and so become the <em>accepted target turn</em> — it runs <em>before</em> delivery is
|
||||||
@@ -933,12 +972,13 @@ public final class MessageService {
|
|||||||
* acceptance means a concurrent sender that times out {@code BUSY} can never steal ownership it
|
* acceptance means a concurrent sender that times out {@code BUSY} can never steal ownership it
|
||||||
* never earned. {@code null} disables the hook.
|
* never earned. {@code null} disables the hook.
|
||||||
*/
|
*/
|
||||||
public Reply send(String target, String content, long timeoutMillis, Runnable onAccepted) {
|
public Reply send(String target, String content, long timeoutMillis, Runnable onAccepted, String callerOwner) {
|
||||||
return send(target, content, timeoutMillis, onAccepted, null);
|
return send(target, content, timeoutMillis, onAccepted, null, callerOwner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run a send, optionally stopping an async task that teardown already failed before acceptance. */
|
/** Run a send, optionally stopping an async task that teardown already failed before acceptance. */
|
||||||
private Reply send(String target, String content, long timeoutMillis, Runnable onAccepted, Task task) {
|
private Reply send(String target, String content, long timeoutMillis, Runnable onAccepted, Task task,
|
||||||
|
String callerOwner) {
|
||||||
long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
|
long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
|
||||||
ReentrantLock lock = sessionLocks.computeIfAbsent(target, _ -> new ReentrantLock());
|
ReentrantLock lock = sessionLocks.computeIfAbsent(target, _ -> new ReentrantLock());
|
||||||
|
|
||||||
@@ -958,7 +998,7 @@ public final class MessageService {
|
|||||||
// open race). Opening first also means a throwing onAccepted (fired before enqueue) or an
|
// open race). Opening first also means a throwing onAccepted (fired before enqueue) or an
|
||||||
// enqueue failure is safely closed by the finally below: nothing is left queued, and the
|
// enqueue failure is safely closed by the finally below: nothing is left queued, and the
|
||||||
// failed send leaves no stale waiter behind.
|
// failed send leaves no stale waiter behind.
|
||||||
CompletableFuture<Rendezvous.Resolution> reply = rendezvous.open(target);
|
CompletableFuture<Rendezvous.Resolution> reply = rendezvous.open(target, Rendezvous.Owner.of(callerOwner));
|
||||||
// CB-640: this send now owns target's delivery, so any earlier stranded-reply or
|
// CB-640: this send now owns target's delivery, so any earlier stranded-reply or
|
||||||
// still-queued fact no longer describes the live state — clear both rather than let
|
// still-queued fact no longer describes the live state — clear both rather than let
|
||||||
// them outlive the send that supersedes them.
|
// them outlive the send that supersedes them.
|
||||||
@@ -1137,19 +1177,30 @@ public final class MessageService {
|
|||||||
* mid-turn (already picked up), so the answer flows back through its own open {@code fleet_ask}
|
* mid-turn (already picked up), so the answer flows back through its own open {@code fleet_ask}
|
||||||
* call, not a new status-gated delivery. The forward waiter is opened <em>before</em> the worker
|
* call, not a new status-gated delivery. The forward waiter is opened <em>before</em> the worker
|
||||||
* is unblocked so a reply that lands the instant it resumes is not lost.
|
* is unblocked so a reply that lands the instant it resumes is not lost.
|
||||||
|
*
|
||||||
|
* <p>{@code callerOwner} identifies the caller making this call. It is checked against the
|
||||||
|
* turn's recorded owner (the caller whose
|
||||||
|
* accepted delegation opened it, see {@link #send(String, String, long, String)} and
|
||||||
|
* {@link #sendAsync(String, String, Runnable, Principal)}) before anything else runs: a mismatch,
|
||||||
|
* including a turn with no owner on record at all, returns {@link Outcome#NOT_TURN_OWNER}
|
||||||
|
* without touching the rendezvous, the session lock, or any async task bookkeeping.
|
||||||
*/
|
*/
|
||||||
public Reply answer(String turnId, String content, long timeoutMillis) {
|
public Reply answer(String turnId, String content, long timeoutMillis, String callerOwner) {
|
||||||
String workerSession = rendezvous.askSession(turnId);
|
String workerSession = rendezvous.askSession(turnId);
|
||||||
if (workerSession == null) {
|
if (workerSession == null) {
|
||||||
return new Reply(Outcome.STALE_TURN, null); // the ask lapsed (timed out or already answered)
|
return new Reply(Outcome.STALE_TURN, null); // the ask lapsed (timed out or already answered)
|
||||||
}
|
}
|
||||||
|
Rendezvous.Owner owner = rendezvous.askOwner(turnId);
|
||||||
|
if (!Rendezvous.Owner.permits(owner, callerOwner)) {
|
||||||
|
return new Reply(Outcome.NOT_TURN_OWNER, null);
|
||||||
|
}
|
||||||
long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
|
long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
|
||||||
ReentrantLock lock = sessionLocks.computeIfAbsent(workerSession, _ -> new ReentrantLock());
|
ReentrantLock lock = sessionLocks.computeIfAbsent(workerSession, _ -> new ReentrantLock());
|
||||||
if (!tryLock(lock, remainingMillis(deadlineNanos))) {
|
if (!tryLock(lock, remainingMillis(deadlineNanos))) {
|
||||||
return new Reply(Outcome.BUSY, null);
|
return new Reply(Outcome.BUSY, null);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
CompletableFuture<Rendezvous.Resolution> reply = rendezvous.open(workerSession);
|
CompletableFuture<Rendezvous.Resolution> reply = rendezvous.open(workerSession, owner);
|
||||||
// fleetd #575: this try used to open below, AFTER the Task lookup/registration and the
|
// fleetd #575: this try used to open below, AFTER the Task lookup/registration and the
|
||||||
// STALE_TURN early return that follows it — so that return was covered only by a
|
// STALE_TURN early return that follows it — so that return was covered only by a
|
||||||
// hand-rolled copy of the finally's own cleanup pair, not the finally itself. Widening the
|
// hand-rolled copy of the finally's own cleanup pair, not the finally itself. Widening the
|
||||||
@@ -1279,8 +1330,20 @@ public final class MessageService {
|
|||||||
* @return the ticket to poll for the eventual result
|
* @return the ticket to poll for the eventual result
|
||||||
*/
|
*/
|
||||||
public String sendAsync(String target, String content, Runnable onAccepted) {
|
public String sendAsync(String target, String content, Runnable onAccepted) {
|
||||||
String ticket = "task-" + ticketSeq.incrementAndGet();
|
return sendAsync(target, content, onAccepted, null);
|
||||||
Task task = new Task(ticket, target, nowNanos);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #sendAsync(String, String, Runnable)}, recording {@code creator}'s owner key as this
|
||||||
|
* ticket's owner. The key is derived here from the resolved principal so callers cannot pass a
|
||||||
|
* terminal address where an owner identity is required.
|
||||||
|
*
|
||||||
|
* @return the ticket to poll for the eventual result
|
||||||
|
*/
|
||||||
|
public String sendAsync(String target, String content, Runnable onAccepted, Principal creator) {
|
||||||
|
String ticket = "task-" + ticketBootNonce + "-" + ticketSeq.incrementAndGet();
|
||||||
|
String creatorOwner = creator == null ? null : creator.ownerKey();
|
||||||
|
Task task = new Task(ticket, target, nowNanos, creatorOwner);
|
||||||
tasks.put(ticket, task);
|
tasks.put(ticket, task);
|
||||||
if (pushLoop != null) {
|
if (pushLoop != null) {
|
||||||
// CB-588: task.future only ever completes on a terminal phase (DONE or a failure) — a
|
// CB-588: task.future only ever completes on a terminal phase (DONE or a failure) — a
|
||||||
@@ -1311,7 +1374,7 @@ public final class MessageService {
|
|||||||
}
|
}
|
||||||
asyncExecutor.submit(() -> {
|
asyncExecutor.submit(() -> {
|
||||||
try {
|
try {
|
||||||
Reply result = send(target, content, ASYNC_TIMEOUT_MS, onAccepted, task);
|
Reply result = send(target, content, ASYNC_TIMEOUT_MS, onAccepted, task, creatorOwner);
|
||||||
if (result.outcome() == Outcome.QUESTION) {
|
if (result.outcome() == Outcome.QUESTION) {
|
||||||
// Keep the accepted owner until answer() finishes it. markAsyncQuestion may run
|
// Keep the accepted owner until answer() finishes it. markAsyncQuestion may run
|
||||||
// just after resolveQuestion wakes this thread.
|
// just after resolveQuestion wakes this thread.
|
||||||
@@ -1343,15 +1406,33 @@ public final class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot the state of an async delegation. Returns {@code null} for an unknown/expired ticket;
|
* As {@link #poll(String, String)}, but bypasses the ownership check entirely via
|
||||||
* otherwise a {@link Phase#PENDING} view (with the live worker status as detail), a
|
* {@link #INTERNAL_NO_OWNER_CHECK}. No production code calls this overload — it exists for
|
||||||
* {@link Phase#DONE} view carrying the reply, or a {@link Phase#FAILED} view with the reason.
|
* tests that only need the ticket's state and have no caller identity to pass.
|
||||||
*/
|
*/
|
||||||
public TaskView poll(String ticket) {
|
public TaskView poll(String ticket) {
|
||||||
|
return poll(ticket, INTERNAL_NO_OWNER_CHECK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the state of an async delegation. Returns {@code null} for an unknown/expired ticket.
|
||||||
|
* Refuses a {@code callerOwner} that differs from the owner that created the ticket (see
|
||||||
|
* {@link #sendAsync(String, String, Runnable, Principal)}) with a {@link Phase#FAILED} view that
|
||||||
|
* carries no reply text. The unnamed primary's owner key is {@code null}, matched the same way
|
||||||
|
* as any other key — it reads a ticket another unnamed primary created, and is refused on a
|
||||||
|
* ticket a named caller created. Otherwise returns a {@link Phase#PENDING} view (with the live
|
||||||
|
* worker status as detail), a {@link Phase#DONE} view carrying the reply, or a
|
||||||
|
* {@link Phase#FAILED} view with the reason.
|
||||||
|
*/
|
||||||
|
public TaskView poll(String ticket, String callerOwner) {
|
||||||
Task task = tasks.get(ticket);
|
Task task = tasks.get(ticket);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!ownsTicket(task, callerOwner)) {
|
||||||
|
return new TaskView(ticket, Phase.FAILED, null, null,
|
||||||
|
"forbidden: this ticket was created by a different session", null);
|
||||||
|
}
|
||||||
CompletableFuture<Reply> f = task.future;
|
CompletableFuture<Reply> f = task.future;
|
||||||
if (!f.isDone()) {
|
if (!f.isDone()) {
|
||||||
Reply question = task.question;
|
Reply question = task.question;
|
||||||
@@ -1359,7 +1440,7 @@ public final class MessageService {
|
|||||||
return new TaskView(ticket, Phase.ASKING, question.text(), null,
|
return new TaskView(ticket, Phase.ASKING, question.text(), null,
|
||||||
"worker is waiting for your answer", question.turnId());
|
"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
|
// 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.
|
// loop it is collected so a later tick's nudge never names a ticket the lead already has.
|
||||||
@@ -1387,6 +1468,30 @@ public final class MessageService {
|
|||||||
return new TaskView(ticket, Phase.FAILED, null, null, detail, null);
|
return new TaskView(ticket, Phase.FAILED, null, null, detail, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker passed as {@code callerOwner} to bypass the ownership check entirely. No
|
||||||
|
* {@link Principal#ownerKey()} ever produces this value — every real key is either
|
||||||
|
* {@code null} (the unnamed primary) or prefixed with its role, such as {@code "worker:"} or
|
||||||
|
* {@code "leader:"}. {@link #poll(String)} passes it; {@link #pendingAsk} has no matching
|
||||||
|
* no-check overload, so this stays package-private for the test that drives the bypass
|
||||||
|
* directly.
|
||||||
|
*/
|
||||||
|
static final String INTERNAL_NO_OWNER_CHECK = "internal:no-owner-check";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether {@code callerOwner} may read {@code task}'s state. {@code callerOwner} is matched
|
||||||
|
* against the task's recorded owner key by equality, including a {@code null} match — the
|
||||||
|
* unnamed primary's owner key is {@code null}, so it owns a ticket another unnamed primary
|
||||||
|
* created and nothing else, the same rule every other role follows. The only caller that
|
||||||
|
* reads any ticket is {@link #INTERNAL_NO_OWNER_CHECK}. This differs from
|
||||||
|
* {@link Rendezvous.Owner#permits}: a missing rendezvous owner is not an authenticated
|
||||||
|
* unnamed primary, so that gate refuses every caller when no owner was recorded.
|
||||||
|
*/
|
||||||
|
private static boolean ownsTicket(Task task, String callerOwner) {
|
||||||
|
return INTERNAL_NO_OWNER_CHECK.equals(callerOwner)
|
||||||
|
|| Objects.equals(callerOwner, task.creatorOwner);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test seam only — carries no production behaviour, and nothing in this class calls it;
|
* Test seam only — carries no production behaviour, and nothing in this class calls it;
|
||||||
* {@link #pruneTerminalTickets} still reads {@link Task#completedNanos} directly.
|
* {@link #pruneTerminalTickets} still reads {@link Task#completedNanos} directly.
|
||||||
@@ -1408,6 +1513,21 @@ public final class MessageService {
|
|||||||
return task != null && task.completedNanos != null;
|
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). */
|
/** Best-effort live worker status for a pending poll; never throws (a lookup error is just noise). */
|
||||||
private String liveStatus(String target) {
|
private String liveStatus(String target) {
|
||||||
try {
|
try {
|
||||||
@@ -1706,22 +1826,84 @@ public final class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The question {@code workerSession} is currently paused on via {@code fleet_ask}, if any
|
* The question {@code workerSession} is currently paused on via {@code fleet_ask}, if any —
|
||||||
* (CB-582) — {@code fleet_status} uses this to show a pending question without the caller
|
* {@code fleet_status} uses this to show a pending question without the caller needing the
|
||||||
* needing the ticket. {@code null} when the session has no open async question (including a
|
* ticket. {@code null} when the session has no open async question (including a session mid a
|
||||||
* session mid a <em>blocking</em> {@code fleet_ask}, which has no {@link Task} to look up — see
|
* <em>blocking</em> {@code fleet_ask}, which has no {@link Task} to look up — see
|
||||||
* {@link PendingAsk}).
|
* {@link PendingAsk}), or when {@code callerOwner} does not own the task the question
|
||||||
|
* belongs to (see {@link #ownsTicket(Task, String)}).
|
||||||
*/
|
*/
|
||||||
public PendingAsk pendingAsk(String workerSession) {
|
public PendingAsk pendingAsk(String workerSession, String callerOwner) {
|
||||||
for (Task task : tasks.values()) {
|
for (Task task : tasks.values()) {
|
||||||
Reply q = task.question;
|
Reply q = task.question;
|
||||||
if (q != null && workerSession.equals(task.target)) {
|
if (q != null && workerSession.equals(task.target) && ownsTicket(task, callerOwner)) {
|
||||||
return new PendingAsk(task.ticket, q.text(), q.turnId());
|
return new PendingAsk(task.ticket, q.text(), q.turnId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ticket {@code callerOwner} created, still present in {@link #tasks}, surfaced by
|
||||||
|
* {@link #outstanding} so a lead can carry its id into a handover file. {@link #phase} is the
|
||||||
|
* same value {@link #poll} would report right now, terminal phases included: a {@code DONE} or
|
||||||
|
* {@code FAILED} ticket stays in {@link #tasks} — and so stays reported here — until
|
||||||
|
* {@link #pruneTerminalTickets} evicts it.
|
||||||
|
*/
|
||||||
|
public record OutstandingTicket(String ticket, Phase phase, String target) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One worker session paused in {@code fleet_ask}, with the {@code turnId} that answers it,
|
||||||
|
* surfaced by {@link #outstanding} alongside {@link OutstandingTicket}.
|
||||||
|
*/
|
||||||
|
public record OutstandingAsk(String ticket, String turnId, String workerSession) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The outstanding tickets and open asks a single call to {@link #outstanding} reports. */
|
||||||
|
public record Outstanding(List<OutstandingTicket> tickets, List<OutstandingAsk> asks) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every ticket {@code callerOwner} created that is still in {@link #tasks} — including a
|
||||||
|
* finished one nobody has polled yet, since {@link #pruneTerminalTickets} discards its reply
|
||||||
|
* on a timer and a lead that does not carry its id forward can no longer read it after losing
|
||||||
|
* its session's context — plus the subset of those whose worker is paused in
|
||||||
|
* {@code fleet_ask}. Filtered by the same ownership rule as {@link #poll}:
|
||||||
|
* {@link #ownsTicket(Task, String)}.
|
||||||
|
*/
|
||||||
|
public Outstanding outstanding(String callerOwner) {
|
||||||
|
List<OutstandingTicket> tickets = new ArrayList<>();
|
||||||
|
List<OutstandingAsk> asks = new ArrayList<>();
|
||||||
|
for (Task task : tasks.values()) {
|
||||||
|
if (!ownsTicket(task, callerOwner)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Reply question = task.question;
|
||||||
|
Phase phase;
|
||||||
|
if (task.future.isDone()) {
|
||||||
|
phase = terminalPhase(task.future);
|
||||||
|
} else if (question != null) {
|
||||||
|
phase = Phase.ASKING;
|
||||||
|
asks.add(new OutstandingAsk(task.ticket, question.turnId(), task.target));
|
||||||
|
} else {
|
||||||
|
phase = Phase.PENDING;
|
||||||
|
}
|
||||||
|
tickets.add(new OutstandingTicket(task.ticket, phase, task.target));
|
||||||
|
}
|
||||||
|
return new Outstanding(tickets, asks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As {@link #poll}'s own terminal-result handling, reduced to just the {@link Phase}. */
|
||||||
|
private static Phase terminalPhase(CompletableFuture<Reply> future) {
|
||||||
|
try {
|
||||||
|
Reply r = future.getNow(null);
|
||||||
|
return r != null && r.completed() ? Phase.DONE : Phase.FAILED;
|
||||||
|
} catch (CompletionException | java.util.concurrent.CancellationException e) {
|
||||||
|
return Phase.FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Release the async executor. */
|
/** Release the async executor. */
|
||||||
public void close() {
|
public void close() {
|
||||||
asyncExecutor.shutdown();
|
asyncExecutor.shutdown();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.ltms.fleet.msg;
|
package dev.ltms.fleet.msg;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
@@ -60,7 +61,7 @@ public final class Rendezvous {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A worker's open mid-turn question: the worker session it belongs to and the answer future. */
|
/** A worker's open mid-turn question: the worker session it belongs to and the answer future. */
|
||||||
private record AskWaiter(String session, CompletableFuture<String> answer) {
|
private record AskWaiter(String session, CompletableFuture<String> answer, Owner owner) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,11 +71,44 @@ public final class Rendezvous {
|
|||||||
public record AskTicket(String turnId, CompletableFuture<String> answer, boolean fresh) {
|
public record AskTicket(String turnId, CompletableFuture<String> answer, boolean fresh) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private final ConcurrentHashMap<String, CompletableFuture<Resolution>> waiters = new ConcurrentHashMap<>();
|
/**
|
||||||
|
* The caller whose accepted delegation opened a turn — the only caller allowed to answer it.
|
||||||
|
* A {@code null} owner key means the unnamed primary.
|
||||||
|
*/
|
||||||
|
public record Owner(String ownerKey) {
|
||||||
|
public static final Owner UNNAMED_PRIMARY = new Owner(null);
|
||||||
|
|
||||||
|
public static Owner of(String ownerKey) {
|
||||||
|
return ownerKey == null ? UNNAMED_PRIMARY : new Owner(ownerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether {@code callerOwner} matches {@code owner}. A {@code null} owner means no owner was
|
||||||
|
* recorded, so it matches no caller. {@link #UNNAMED_PRIMARY} records the unnamed primary
|
||||||
|
* with an owner object whose key is {@code null}.
|
||||||
|
*/
|
||||||
|
public static boolean permits(Owner owner, String callerOwner) {
|
||||||
|
return owner != null && java.util.Objects.equals(owner.ownerKey(), callerOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A registered forward waiter together with the owner its delegation was opened under. */
|
||||||
|
private record ForwardWaiter(Owner owner, CompletableFuture<Resolution> future) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, ForwardWaiter> waiters = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/** Reverse rendezvous (CB-205): worker questions awaiting the primary's answer, keyed by {@code turnId}. */
|
/** Reverse rendezvous (CB-205): worker questions awaiting the primary's answer, keyed by {@code turnId}. */
|
||||||
private final ConcurrentHashMap<String, AskWaiter> asks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, AskWaiter> asks = new ConcurrentHashMap<>();
|
||||||
private final AtomicLong askSeq = new AtomicLong();
|
private final AtomicLong askSeq = new AtomicLong();
|
||||||
|
/**
|
||||||
|
* Minted once per {@code Rendezvous} instance and folded into every {@code turnId} (see
|
||||||
|
* {@link #openAsk(String)}). {@link #askSeq} alone restarts at zero for every instance, so
|
||||||
|
* without this a {@code turnId} minted by one instance could be minted again by another and
|
||||||
|
* resolve to an unrelated ask with no error; this nonce makes that impossible, because an id
|
||||||
|
* minted by one instance can never match the id space of another.
|
||||||
|
*/
|
||||||
|
private final String askBootNonce = UUID.randomUUID().toString().substring(0, 6);
|
||||||
/** Per-session index of the currently-open ask, so duplicate fleet_ask calls coalesce onto one turn. */
|
/** Per-session index of the currently-open ask, so duplicate fleet_ask calls coalesce onto one turn. */
|
||||||
private final ConcurrentHashMap<String, String> openAsksBySession = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, String> openAsksBySession = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -89,8 +123,17 @@ public final class Rendezvous {
|
|||||||
* code a double open is impossible; this is a tripwire for the day that no longer holds.
|
* code a double open is impossible; this is a tripwire for the day that no longer holds.
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<Resolution> open(String session) {
|
public CompletableFuture<Resolution> open(String session) {
|
||||||
|
return open(session, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #open(String)}, additionally recording {@code owner} as the caller whose
|
||||||
|
* delegation opened this waiter. A {@code null} owner records no owner at all — the
|
||||||
|
* fail-closed default {@link Owner#permits} refuses to everyone.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<Resolution> open(String session, Owner owner) {
|
||||||
CompletableFuture<Resolution> waiter = new CompletableFuture<>();
|
CompletableFuture<Resolution> waiter = new CompletableFuture<>();
|
||||||
CompletableFuture<Resolution> existing = waiters.putIfAbsent(session, waiter);
|
ForwardWaiter existing = waiters.putIfAbsent(session, new ForwardWaiter(owner, waiter));
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"rendezvous double-open for session " + session + " — a waiter is already registered");
|
"rendezvous double-open for session " + session + " — a waiter is already registered");
|
||||||
@@ -105,7 +148,13 @@ public final class Rendezvous {
|
|||||||
* successful {@code open} after a finished turn requires this close to have happened first).
|
* successful {@code open} after a finished turn requires this close to have happened first).
|
||||||
*/
|
*/
|
||||||
public void close(String session, CompletableFuture<Resolution> waiter) {
|
public void close(String session, CompletableFuture<Resolution> waiter) {
|
||||||
waiters.remove(session, waiter);
|
waiters.computeIfPresent(session, (s, w) -> w.future() == waiter ? null : w);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner recorded for {@code session}'s open waiter, or {@code null} if none is open. */
|
||||||
|
public Owner ownerOf(String session) {
|
||||||
|
ForwardWaiter w = waiters.get(session);
|
||||||
|
return w == null ? null : w.owner();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a send is currently awaiting a resolution for {@code session}. */
|
/** Whether a send is currently awaiting a resolution for {@code session}. */
|
||||||
@@ -119,7 +168,8 @@ public final class Rendezvous {
|
|||||||
* send (see the CB-116 note above) rather than whichever send happens to be waiting when they fire.
|
* send (see the CB-116 note above) rather than whichever send happens to be waiting when they fire.
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<Resolution> currentWaiter(String session) {
|
public CompletableFuture<Resolution> currentWaiter(String session) {
|
||||||
return waiters.get(session);
|
ForwardWaiter w = waiters.get(session);
|
||||||
|
return w == null ? null : w.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,9 +195,9 @@ public final class Rendezvous {
|
|||||||
while (true) {
|
while (true) {
|
||||||
AskWaiter[] minted = { null };
|
AskWaiter[] minted = { null };
|
||||||
String turnId = openAsksBySession.computeIfAbsent(session, _ -> {
|
String turnId = openAsksBySession.computeIfAbsent(session, _ -> {
|
||||||
String newTurnId = session + "#" + askSeq.incrementAndGet();
|
String newTurnId = session + "#" + askBootNonce + "-" + askSeq.incrementAndGet();
|
||||||
CompletableFuture<String> answer = new CompletableFuture<>();
|
CompletableFuture<String> answer = new CompletableFuture<>();
|
||||||
AskWaiter waiter = new AskWaiter(session, answer);
|
AskWaiter waiter = new AskWaiter(session, answer, ownerOf(session));
|
||||||
asks.put(newTurnId, waiter);
|
asks.put(newTurnId, waiter);
|
||||||
minted[0] = waiter;
|
minted[0] = waiter;
|
||||||
return newTurnId;
|
return newTurnId;
|
||||||
@@ -183,6 +233,16 @@ public final class Rendezvous {
|
|||||||
return w == null ? null : w.session();
|
return w == null ? null : w.session();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner recorded for {@code turnId} when its ask turn was freshly opened — the caller
|
||||||
|
* whose delegation {@link #answerAsk} must match. {@code null} if {@code turnId} is unknown or
|
||||||
|
* lapsed, or if the ask opened with no forward waiter owner on record.
|
||||||
|
*/
|
||||||
|
public Owner askOwner(String turnId) {
|
||||||
|
AskWaiter w = asks.get(turnId);
|
||||||
|
return w == null ? null : w.owner();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a worker's blocked {@code fleet_ask} with the primary's {@code answer}, unblocking it
|
* Resolve a worker's blocked {@code fleet_ask} with the primary's {@code answer}, unblocking it
|
||||||
* to resume its turn.
|
* to resume its turn.
|
||||||
@@ -245,7 +305,7 @@ public final class Rendezvous {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean complete(String session, Resolution resolution) {
|
private boolean complete(String session, Resolution resolution) {
|
||||||
CompletableFuture<Resolution> waiter = waiters.get(session);
|
ForwardWaiter waiter = waiters.get(session);
|
||||||
return waiter != null && waiter.complete(resolution);
|
return waiter != null && waiter.future().complete(resolution);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package dev.ltms.fleet.msg;
|
|||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.AgentStatus;
|
import dev.ltms.fleet.herdr.AgentStatus;
|
||||||
import dev.ltms.fleet.herdr.HerdrException;
|
import dev.ltms.fleet.herdr.HerdrException;
|
||||||
|
import dev.ltms.fleet.herdr.PromptBox;
|
||||||
import dev.ltms.fleet.mcp.PrimaryRegistry;
|
import dev.ltms.fleet.mcp.PrimaryRegistry;
|
||||||
import dev.ltms.fleet.metrics.FleetMetrics;
|
import dev.ltms.fleet.metrics.FleetMetrics;
|
||||||
import dev.ltms.fleet.metrics.Metrics;
|
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
|
* 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
|
* {@link #decide}) — whichever the durable inbox / pending set doesn't already answer via
|
||||||
* {@code STOP}.
|
* {@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 {
|
public final class ReplyPushLoop {
|
||||||
|
|
||||||
@@ -81,6 +87,7 @@ public final class ReplyPushLoop {
|
|||||||
|
|
||||||
private final PrimaryRegistry primaryRegistry;
|
private final PrimaryRegistry primaryRegistry;
|
||||||
private final AgentControl agents;
|
private final AgentControl agents;
|
||||||
|
private final PromptBox promptBox;
|
||||||
private final ReplyInbox inbox;
|
private final ReplyInbox inbox;
|
||||||
private final ScheduledExecutorService scheduler;
|
private final ScheduledExecutorService scheduler;
|
||||||
private final int maxReminders;
|
private final int maxReminders;
|
||||||
@@ -125,6 +132,7 @@ public final class ReplyPushLoop {
|
|||||||
int maxReminders, long backoffMs, Metrics metrics) {
|
int maxReminders, long backoffMs, Metrics metrics) {
|
||||||
this.primaryRegistry = primaryRegistry;
|
this.primaryRegistry = primaryRegistry;
|
||||||
this.agents = agents;
|
this.agents = agents;
|
||||||
|
this.promptBox = new PromptBox(agents);
|
||||||
this.inbox = inbox;
|
this.inbox = inbox;
|
||||||
this.scheduler = scheduler;
|
this.scheduler = scheduler;
|
||||||
this.maxReminders = maxReminders;
|
this.maxReminders = maxReminders;
|
||||||
@@ -398,11 +406,15 @@ public final class ReplyPushLoop {
|
|||||||
log.debug("push: status check failed for lead {}, will retry", lead, e);
|
log.debug("push: status check failed for lead {}, will retry", lead, e);
|
||||||
return Action.WAIT_BUSY;
|
return Action.WAIT_BUSY;
|
||||||
}
|
}
|
||||||
if (status.injectable()) {
|
if (!status.injectable()) {
|
||||||
return Action.INJECT;
|
log.debug("push: lead {} is {} (not injectable), waiting", lead, status);
|
||||||
|
return Action.WAIT_BUSY;
|
||||||
}
|
}
|
||||||
log.debug("push: lead {} is {} (not injectable), waiting", lead, status);
|
if (!promptBox.clearToSubmit(lead)) {
|
||||||
return Action.WAIT_BUSY;
|
log.debug("push: lead {} has unsubmitted text in its prompt box, waiting", lead);
|
||||||
|
return Action.WAIT_BUSY;
|
||||||
|
}
|
||||||
|
return Action.INJECT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -439,6 +451,15 @@ public final class ReplyPushLoop {
|
|||||||
* — timeout, transport error, a decode error — is treated as still live and the binding is left
|
* — timeout, transport error, a decode error — is treated as still live and the binding is left
|
||||||
* alone, because guessing wrong here is unrecoverable while guessing "live" merely costs one more
|
* alone, because guessing wrong here is unrecoverable while guessing "live" merely costs one more
|
||||||
* retry on the next tick, which {@link #decide} already tolerates.
|
* retry on the next tick, which {@link #decide} already tolerates.
|
||||||
|
*
|
||||||
|
* <p><strong>The fallback is probed too.</strong> {@code PrimaryRegistry.nudgeTargetFor} already
|
||||||
|
* resolves a named delegator to its current terminal before this method ever sees it, which
|
||||||
|
* keeps a rolled lead's per-target binding live. What that resolution cannot fix is a caller
|
||||||
|
* that was never recorded with a name at all — an unnamed primary, or a lead whose tab the
|
||||||
|
* scanner cannot currently see — where the fallback it returns is still the raw terminal last
|
||||||
|
* learned from call traffic. This method returns that fallback only after the same liveness
|
||||||
|
* check, and gives up for this tick (an empty result, exactly like "no lead known at all") rather
|
||||||
|
* than hand a caller a second stale address un-probed.
|
||||||
*/
|
*/
|
||||||
private Optional<String> resolveLiveLead(String target) {
|
private Optional<String> resolveLiveLead(String target) {
|
||||||
Optional<String> lead = primaryRegistry.nudgeTargetFor(target);
|
Optional<String> lead = primaryRegistry.nudgeTargetFor(target);
|
||||||
@@ -448,7 +469,12 @@ public final class ReplyPushLoop {
|
|||||||
log.debug("push: lead {} delegated to for {} is no longer live, forgetting the stale binding "
|
log.debug("push: lead {} delegated to for {} is no longer live, forgetting the stale binding "
|
||||||
+ "and falling back", lead.get(), target);
|
+ "and falling back", lead.get(), target);
|
||||||
primaryRegistry.forgetDelegation(target);
|
primaryRegistry.forgetDelegation(target);
|
||||||
return primaryRegistry.nudgeTargetFor(target);
|
Optional<String> fallback = primaryRegistry.nudgeTargetFor(target);
|
||||||
|
if (fallback.isEmpty() || isLive(fallback.get())) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
log.debug("push: fallback lead {} for {} is also not live, skipping this tick", fallback.get(), target);
|
||||||
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package dev.ltms.fleet.peer;
|
package dev.ltms.fleet.peer;
|
||||||
|
|
||||||
import java.util.Locale;
|
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.
|
* What a member is <em>for</em> — the contract it runs under.
|
||||||
@@ -100,6 +102,11 @@ public enum MemberRole {
|
|||||||
return null;
|
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.
|
* 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(
|
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.Agent;
|
||||||
import dev.ltms.fleet.herdr.HerdrClient;
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
import dev.ltms.fleet.herdr.HerdrException;
|
import dev.ltms.fleet.herdr.HerdrException;
|
||||||
|
import dev.ltms.fleet.herdr.PaneLocator;
|
||||||
import dev.ltms.fleet.inject.MemberPresence;
|
import dev.ltms.fleet.inject.MemberPresence;
|
||||||
import dev.ltms.fleet.member.MemberCredentialPolicyView;
|
import dev.ltms.fleet.member.MemberCredentialPolicyView;
|
||||||
import dev.ltms.fleet.peer.PeerUnreachableException;
|
import dev.ltms.fleet.peer.PeerUnreachableException;
|
||||||
@@ -49,22 +50,52 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
public final class FleetApp {
|
public final class FleetApp {
|
||||||
|
|
||||||
/** The authorization action the matching route handler hands to {@link #allow}. */
|
/**
|
||||||
|
* The authorization action the matching route handler hands to {@link #allow}, for a route
|
||||||
|
* whose action does not depend on the request body.
|
||||||
|
*/
|
||||||
static Authz.Action routeAction(String route) {
|
static Authz.Action routeAction(String route) {
|
||||||
|
return routeAction(route, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, plus the one route whose action depends on the body: {@code POST
|
||||||
|
* /sessions/{id}/message} carries a {@code turnId} (the answer-a-blocked-worker shape) or not
|
||||||
|
* (a plain delivery), mirroring {@code FleetMcp#sendAction}'s split of the same two call
|
||||||
|
* shapes over MCP. {@code turnId} is ignored by every other route.
|
||||||
|
*
|
||||||
|
* @param turnId the request body's {@code turnId}, or {@code null}/blank when absent or not
|
||||||
|
* applicable to this route
|
||||||
|
*/
|
||||||
|
static Authz.Action routeAction(String route, String turnId) {
|
||||||
return switch (route) {
|
return switch (route) {
|
||||||
case "GET /metrics" -> Authz.Action.METRICS;
|
case "GET /metrics" -> Authz.Action.METRICS;
|
||||||
case "POST /members" -> Authz.Action.SPAWN;
|
case "POST /members" -> Authz.Action.SPAWN;
|
||||||
case "DELETE /members/{paneId}" -> Authz.Action.STOP;
|
case "DELETE /members/{paneId}" -> Authz.Action.STOP;
|
||||||
case "POST /sessions/{id}/message" -> Authz.Action.SEND;
|
case "POST /sessions/{id}/message" -> turnId == null || turnId.isBlank()
|
||||||
|
? Authz.Action.SEND : Authz.Action.ANSWER;
|
||||||
case "POST /sessions/{id}/reply" -> Authz.Action.REPLY;
|
case "POST /sessions/{id}/reply" -> Authz.Action.REPLY;
|
||||||
case "GET /sessions/{id}/replies" -> Authz.Action.DRAIN;
|
case "GET /sessions/{id}/replies" -> Authz.Action.DRAIN;
|
||||||
case "POST /sessions/{id}/ask" -> Authz.Action.ASK;
|
case "POST /sessions/{id}/ask" -> Authz.Action.ASK;
|
||||||
case "GET /sessions", "GET /agents", "GET /members", "GET /profiles",
|
case "GET /sessions", "GET /agents", "GET /members", "GET /profiles",
|
||||||
"GET /member-credentials", "GET /sessions/{id}/status", "GET /tasks/{ticket}" -> Authz.Action.READ;
|
"GET /member-credentials" -> Authz.Action.READ;
|
||||||
|
case "GET /sessions/{id}/status", "GET /tasks/{ticket}" -> Authz.Action.TASK_READ;
|
||||||
default -> throw new IllegalArgumentException("route has no authorization gate: " + route);
|
default -> throw new IllegalArgumentException("route has no authorization gate: " + route);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second gate for {@code POST /sessions/{id}/message}: checked only when {@code turnId}
|
||||||
|
* is present and non-blank, against {@link Authz.Action#ANSWER}. A request with no {@code
|
||||||
|
* turnId} passes this gate unconditionally, without consulting {@code permit} at all, having
|
||||||
|
* already cleared the coarse {@link Authz.Action#SEND} grant checked ahead of it.
|
||||||
|
*
|
||||||
|
* @param permit reports whether the caller holds the named grant
|
||||||
|
*/
|
||||||
|
static boolean answerGatePasses(String turnId, Predicate<Authz.Action> permit) {
|
||||||
|
return turnId == null || turnId.isBlank() || permit.test(Authz.Action.ANSWER);
|
||||||
|
}
|
||||||
|
|
||||||
/** Default blocking window for a message; kept under typical HTTP idle timeouts. */
|
/** Default blocking window for a message; kept under typical HTTP idle timeouts. */
|
||||||
private static final long DEFAULT_MESSAGE_TIMEOUT_MS = 25_000;
|
private static final long DEFAULT_MESSAGE_TIMEOUT_MS = 25_000;
|
||||||
private static final long MAX_MESSAGE_TIMEOUT_MS = 120_000;
|
private static final long MAX_MESSAGE_TIMEOUT_MS = 120_000;
|
||||||
@@ -236,6 +267,32 @@ public final class FleetApp {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authorization decision behind {@link #allow}, taking the caller directly rather than
|
||||||
|
* pulling it from a servlet {@link Context} — unit-testable without fabricating a live
|
||||||
|
* request, the same reason {@code FleetMcp#denyFor} is split from {@code FleetMcp#deny}.
|
||||||
|
*
|
||||||
|
* @param knownLeadOrCollaborator the classifier a collaborator's {@code SEND} is checked
|
||||||
|
* against; pass {@link #auth}'s own {@code
|
||||||
|
* knownLeadOrCollaborator()} to exercise the real production
|
||||||
|
* gate, as {@link #allow} does
|
||||||
|
*/
|
||||||
|
static boolean permitsFor(Principal caller, Authz.Action action, String target,
|
||||||
|
Predicate<String> knownLeadOrCollaborator) {
|
||||||
|
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
|
* 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}.
|
* proceed; otherwise writes the error response and returns {@code false}.
|
||||||
@@ -249,8 +306,9 @@ public final class FleetApp {
|
|||||||
return true; // legacy: authorization not enforced
|
return true; // legacy: authorization not enforced
|
||||||
}
|
}
|
||||||
Principal caller = ctx.attribute(CALLER);
|
Principal caller = ctx.attribute(CALLER);
|
||||||
if (Authz.permits(caller, action, target)) {
|
if (permitsFor(caller, action, target, auth.knownLeadOrCollaborator(), auth.observerSendTarget())) {
|
||||||
if (action != Authz.Action.READ && action != Authz.Action.METRICS) {
|
if (action != Authz.Action.READ && action != Authz.Action.METRICS
|
||||||
|
&& action != Authz.Action.TASK_READ) {
|
||||||
AuditLog.allowed(caller, action, target); // reads would drown the trail
|
AuditLog.allowed(caller, action, target); // reads would drown the trail
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -390,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. */
|
/** Discovery: every agent herdr tracks, keyed by its Claude session UUID. */
|
||||||
private void agents(Context ctx) {
|
private void agents(Context ctx) {
|
||||||
if (!allow(ctx, routeAction("GET /agents"), null)) {
|
if (!allow(ctx, routeAction("GET /agents"), null)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final Map<String, String> tabLabels = tabLabelsOrEmpty();
|
||||||
try {
|
try {
|
||||||
ctx.status(200).json(Map.of("agents",
|
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) {
|
} catch (HerdrException e) {
|
||||||
// fleetd #297: workers.list() reaches herdr — a transport failure must land in the same
|
// 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
|
// {error, detail} envelope every other failure path here uses, not escape as a bare
|
||||||
@@ -603,47 +674,64 @@ public final class FleetApp {
|
|||||||
* status-gated injector and block until the worker returns a structured {@code fleet_reply}.
|
* status-gated injector and block until the worker returns a structured {@code fleet_reply}.
|
||||||
* Times out with a typed 202 (working / queued / busy) rather than an error — the message may
|
* Times out with a typed 202 (working / queued / busy) rather than an error — the message may
|
||||||
* still land.
|
* still land.
|
||||||
|
*
|
||||||
|
* <p>Two call shapes share this route, exactly as {@code fleet_send} does over MCP (see
|
||||||
|
* {@code FleetMcp#sendAction}): a plain delivery to {@code id}, and -- when the body carries
|
||||||
|
* {@code turnId} -- resolving a worker's blocked question. The coarse {@link
|
||||||
|
* Authz.Action#SEND} grant is checked first, before the body is read at all; only once that
|
||||||
|
* passes is the body parsed, and a present {@code turnId} is then checked again against
|
||||||
|
* {@link Authz.Action#ANSWER}. A body that fails to parse is rejected with 400 and reaches
|
||||||
|
* neither {@code messages.answer} nor {@code messages.send}.
|
||||||
*/
|
*/
|
||||||
private void sendMessage(Context ctx) {
|
private void sendMessage(Context ctx) {
|
||||||
String id = ctx.pathParam("id");
|
String id = ctx.pathParam("id");
|
||||||
if (!allow(ctx, routeAction("POST /sessions/{id}/message"), id)) {
|
if (!allow(ctx, routeAction("POST /sessions/{id}/message"), id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String content;
|
Principal caller = ctx.attribute(CALLER);
|
||||||
String turnId;
|
String callerOwner = caller == null ? null : caller.ownerKey();
|
||||||
long timeout;
|
JsonNode body;
|
||||||
boolean wait;
|
|
||||||
try {
|
try {
|
||||||
JsonNode body = mapper.readTree(ctx.body());
|
body = mapper.readTree(ctx.body());
|
||||||
content = body.path("content").asText("");
|
|
||||||
turnId = body.path("turnId").asText(null);
|
|
||||||
timeout = body.path("timeoutMs").asLong(DEFAULT_MESSAGE_TIMEOUT_MS);
|
|
||||||
wait = body.path("wait").asBoolean(true); // default: block for the reply (CB-104)
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
if (body == null) {
|
||||||
ctx.status(400).json(Map.of("error", "bad_request", "detail", "body must be JSON"));
|
ctx.status(400).json(Map.of("error", "bad_request", "detail", "body must be JSON"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String turnId = body.path("turnId").asText(null);
|
||||||
|
if (!answerGatePasses(turnId, action -> allow(ctx, action, id))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String content = body.path("content").asText("");
|
||||||
|
long timeout = body.path("timeoutMs").asLong(DEFAULT_MESSAGE_TIMEOUT_MS);
|
||||||
|
boolean wait = body.path("wait").asBoolean(true); // default: block for the reply (CB-104)
|
||||||
if (content.isBlank()) {
|
if (content.isBlank()) {
|
||||||
ctx.status(400).json(Map.of("error", "bad_request", "detail", "content is required"));
|
ctx.status(400).json(Map.of("error", "bad_request", "detail", "content is required"));
|
||||||
return;
|
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);
|
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.
|
// Answering a worker's fleet_ask (CB-205): always blocks, and derives the worker from turnId.
|
||||||
if (turnId != null && !turnId.isBlank()) {
|
if (turnId != null && !turnId.isBlank()) {
|
||||||
writeReply(ctx, id, messages.answer(turnId, content, timeout), timeout);
|
writeReply(ctx, id, messages.answer(turnId, content, timeout, callerOwner), timeout);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wait) {
|
if (!wait) {
|
||||||
// Fire-and-poll (CB-107): return a ticket immediately; the caller polls GET /tasks/{ticket}.
|
// Fire-and-poll (CB-107): return a ticket immediately; the caller polls GET /tasks/{ticket}.
|
||||||
String ticket = messages.sendAsync(id, content);
|
String ticket = messages.sendAsync(id, content, null, caller);
|
||||||
ctx.status(202).json(Map.of("sessionId", id, "ticket", ticket, "status", "accepted"));
|
ctx.status(202).json(Map.of("sessionId", id, "ticket", ticket, "status", "accepted"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeReply(ctx, id, messages.send(id, content, timeout), timeout);
|
writeReply(ctx, id, messages.send(id, content, timeout, callerOwner), timeout);
|
||||||
} catch (HerdrException e) {
|
} catch (HerdrException e) {
|
||||||
herdrError(ctx, e);
|
herdrError(ctx, e);
|
||||||
}
|
}
|
||||||
@@ -662,6 +750,10 @@ public final class FleetApp {
|
|||||||
case STALE_TURN -> ctx.status(409).json(Map.of(
|
case STALE_TURN -> ctx.status(409).json(Map.of(
|
||||||
"sessionId", id, "error", "stale_turn",
|
"sessionId", id, "error", "stale_turn",
|
||||||
"detail", "that question is no longer open (timed out or already answered)"));
|
"detail", "that question is no longer open (timed out or already answered)"));
|
||||||
|
case NOT_TURN_OWNER -> ctx.status(403).json(Map.of(
|
||||||
|
"sessionId", id, "error", "not_turn_owner",
|
||||||
|
"detail", "this turn belongs to a different delegation — only the caller that "
|
||||||
|
+ "opened it may answer it"));
|
||||||
case REPLIED, COMPLETED_UNREPLIED -> {
|
case REPLIED, COMPLETED_UNREPLIED -> {
|
||||||
// replySource distinguishes a structured fleet_reply from the CB-106 completion
|
// replySource distinguishes a structured fleet_reply from the CB-106 completion
|
||||||
// fallback (a scrape of the worker's transcript when it finished without replying).
|
// fallback (a scrape of the worker's transcript when it finished without replying).
|
||||||
@@ -676,9 +768,10 @@ public final class FleetApp {
|
|||||||
// silent fall-through. That is exactly the bug this ticket exists to fix:
|
// silent fall-through. That is exactly the bug this ticket exists to fix:
|
||||||
// `default -> "done"` used to sit here and would have told a REST caller the
|
// `default -> "done"` used to sit here and would have told a REST caller the
|
||||||
// delegation completed for TIMED_OUT_UNCONFIRMED, the one outcome where delivery
|
// delegation completed for TIMED_OUT_UNCONFIRMED, the one outcome where delivery
|
||||||
// is unknown. REPLIED, COMPLETED_UNREPLIED, QUESTION and STALE_TURN can never
|
// is unknown. REPLIED, COMPLETED_UNREPLIED, QUESTION, STALE_TURN and
|
||||||
// actually reach this inner switch — the outer switch above always dispatches
|
// NOT_TURN_OWNER can never actually reach this inner switch — the outer switch
|
||||||
// them first — but they still need an arm to keep this switch exhaustive.
|
// above always dispatches them first — but they still need an arm to keep this
|
||||||
|
// switch exhaustive.
|
||||||
"status", switch (reply.outcome()) {
|
"status", switch (reply.outcome()) {
|
||||||
case TIMED_OUT_WORKING -> "working";
|
case TIMED_OUT_WORKING -> "working";
|
||||||
case TIMED_OUT_QUEUED -> "queued";
|
case TIMED_OUT_QUEUED -> "queued";
|
||||||
@@ -688,7 +781,7 @@ public final class FleetApp {
|
|||||||
case BUSY -> "busy";
|
case BUSY -> "busy";
|
||||||
case WORKER_FAILED -> "failed";
|
case WORKER_FAILED -> "failed";
|
||||||
case BACKEND_EXHAUSTED -> "backend_exhausted";
|
case BACKEND_EXHAUSTED -> "backend_exhausted";
|
||||||
case REPLIED, COMPLETED_UNREPLIED, QUESTION, STALE_TURN -> "done"; // unreachable
|
case REPLIED, COMPLETED_UNREPLIED, QUESTION, STALE_TURN, NOT_TURN_OWNER -> "done"; // unreachable
|
||||||
},
|
},
|
||||||
"detail", reply.outcome() == MessageService.Outcome.TIMED_OUT_UNCONFIRMED
|
"detail", reply.outcome() == MessageService.Outcome.TIMED_OUT_UNCONFIRMED
|
||||||
? "no reply within " + timeout + "ms; delivery is unconfirmed — the "
|
? "no reply within " + timeout + "ms; delivery is unconfirmed — the "
|
||||||
@@ -817,10 +910,11 @@ public final class FleetApp {
|
|||||||
body.put("sessionId", id);
|
body.put("sessionId", id);
|
||||||
body.put("status", messages.status(id).name().toLowerCase());
|
body.put("status", messages.status(id).name().toLowerCase());
|
||||||
body.put("ready", deliverable.test(id));
|
body.put("ready", deliverable.test(id));
|
||||||
// CB-582: a worker paused mid-turn in an async fleet_ask is otherwise invisible to a
|
// A worker paused mid-turn in an async fleet_ask is otherwise invisible to a status
|
||||||
// status poll — surface the open question and how to answer it, same as fleet_poll's
|
// poll — surface the open question and how to answer it, same as fleet_poll's
|
||||||
// Phase.ASKING view.
|
// Phase.ASKING view, but only to the caller whose owner key created that delegation.
|
||||||
MessageService.PendingAsk ask = messages.pendingAsk(id);
|
Principal caller = ctx.attribute(CALLER);
|
||||||
|
MessageService.PendingAsk ask = messages.pendingAsk(id, caller == null ? null : caller.ownerKey());
|
||||||
if (ask != null) {
|
if (ask != null) {
|
||||||
body.put("question", ask.question());
|
body.put("question", ask.question());
|
||||||
body.put("turnId", ask.turnId());
|
body.put("turnId", ask.turnId());
|
||||||
@@ -837,7 +931,8 @@ public final class FleetApp {
|
|||||||
if (!allow(ctx, routeAction("GET /tasks/{ticket}"), null)) {
|
if (!allow(ctx, routeAction("GET /tasks/{ticket}"), null)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MessageService.TaskView v = messages.poll(ctx.pathParam("ticket"));
|
Principal caller = ctx.attribute(CALLER);
|
||||||
|
MessageService.TaskView v = messages.poll(ctx.pathParam("ticket"), caller == null ? null : caller.ownerKey());
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
ctx.status(404).json(Map.of("error", "unknown_ticket", "detail", "no such task (or it has expired)"));
|
ctx.status(404).json(Map.of("error", "unknown_ticket", "detail", "no such task (or it has expired)"));
|
||||||
return;
|
return;
|
||||||
@@ -869,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<>();
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
m.put("terminalId", a.terminalId());
|
m.put("terminalId", a.terminalId());
|
||||||
m.put("paneId", a.paneId());
|
m.put("paneId", a.paneId());
|
||||||
m.put("workspaceId", a.workspaceId());
|
m.put("workspaceId", a.workspaceId());
|
||||||
m.put("tabId", a.tabId());
|
m.put("tabId", a.tabId());
|
||||||
|
m.put("label", tabLabels.get(a.tabId()));
|
||||||
m.put("sessionId", a.sessionId());
|
m.put("sessionId", a.sessionId());
|
||||||
m.put("agentType", a.agentType());
|
m.put("agentType", a.agentType());
|
||||||
m.put("status", a.status().name().toLowerCase());
|
m.put("status", a.status().name().toLowerCase());
|
||||||
|
|||||||
@@ -442,38 +442,6 @@ public final class GitWorktrees implements Worktrees {
|
|||||||
ENVIRONMENT_CREDENTIAL_HELPER);
|
ENVIRONMENT_CREDENTIAL_HELPER);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link #configureEnvironmentCredentialHelper} only ever fires for an HTTPS origin — Git never
|
|
||||||
* consults a {@code credential.helper} for an SSH transport. This repo's own origin is
|
|
||||||
* {@code ssh://git@git.ltms.dev:2224/fleet/fleetd.git}, so a member sitting on that origin never
|
|
||||||
* reaches the helper and the repo-scoped {@code WORKER_GITEA_TOKEN} is simply not used.
|
|
||||||
*
|
|
||||||
* <p>An earlier version of this javadoc justified the rewrite by claiming a member <em>cannot</em>
|
|
||||||
* push once {@code memberCredentials.policy: allow-list} blocks {@code SSH_AUTH_SOCK}, because
|
|
||||||
* "there is no private key file on this host, only an ssh-agent socket". That premise is false
|
|
||||||
* (fleetd #184): {@code ssh -G} resolves a readable, passphrase-free {@code IdentityFile} outside
|
|
||||||
* {@code ~/.ssh}, and a member — same OS user — pushes over SSH with the socket blanked. The
|
|
||||||
* rewrite is still worth having, but for the reason below rather than that one: it routes the
|
|
||||||
* member through its own scoped token instead of the operator's ssh identity, which is what makes
|
|
||||||
* a member's pushes attributable and revocable.
|
|
||||||
*
|
|
||||||
* <p>The fix is a <em>worktree-scoped</em> URL rewrite: {@code url.<https-base>.insteadOf
|
|
||||||
* <ssh-base>}, set with {@code --worktree} so it lands only in
|
|
||||||
* {@code <worktree>/.git/worktrees/<name>/config.worktree} (enabled by
|
|
||||||
* {@code extensions.worktreeConfig}, already turned on above) and never touches the shared
|
|
||||||
* repo-level config the primary checkout also reads. {@code insteadOf} — not
|
|
||||||
* {@code pushInsteadOf} — because a member may also need to fetch or rebase, and both should go
|
|
||||||
* through the member's own token for the same reason.
|
|
||||||
*
|
|
||||||
* <p>The host (and, for the rewrite's SSH-side match, the port) come from parsing the origin
|
|
||||||
* itself — never a hardcoded forge host, which is exactly what #177 removed. An origin that is
|
|
||||||
* already {@code https://} is left alone; the credential helper already covers it. An origin
|
|
||||||
* that is neither {@code ssh://} nor {@code https://} — including the scp-like shorthand
|
|
||||||
* ({@code git@host:path}, no scheme) — is left untouched deliberately: that shorthand's
|
|
||||||
* {@code host:path} split is defined by the user's ssh_config aliases, not by URI syntax, so
|
|
||||||
* guessing at it risks rewriting to the wrong place. A repo provisioned from that form keeps
|
|
||||||
* today's (broken, if the policy blocks the agent) SSH-only behaviour rather than a wrong rewrite.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Blank the user-info of a remote URL before it reaches a log. A remote URL is not obviously a
|
* Blank the user-info of a remote URL before it reaches a log. A remote URL is not obviously a
|
||||||
* credential channel, which is exactly why one has leaked here three times ({@code git remote -v}
|
* credential channel, which is exactly why one has leaked here three times ({@code git remote -v}
|
||||||
@@ -485,6 +453,30 @@ public final class GitWorktrees implements Worktrees {
|
|||||||
return url == null ? null : url.replaceAll("://[^@/]*@", "://<redacted>@");
|
return url == null ? null : url.replaceAll("://[^@/]*@", "://<redacted>@");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link #configureEnvironmentCredentialHelper} only fires for an HTTPS origin — Git never
|
||||||
|
* consults a {@code credential.helper} for an SSH transport. This repo's own origin is
|
||||||
|
* {@code ssh://git@git.ltms.dev:2224/fleet/fleetd.git}, so a member on that origin never
|
||||||
|
* reaches the helper, and the repo-scoped token goes unused without a separate rewrite.
|
||||||
|
*
|
||||||
|
* <p>This method routes the member through its own scoped token instead of the operator's ssh
|
||||||
|
* identity, which is what makes a member's pushes attributable and revocable.
|
||||||
|
*
|
||||||
|
* <p>The fix is a <em>worktree-scoped</em> URL rewrite: {@code url.<https-base>.insteadOf
|
||||||
|
* <ssh-base>}, set with {@code --worktree} so it lands only in
|
||||||
|
* {@code <worktree>/.git/worktrees/<name>/config.worktree} and never touches the shared
|
||||||
|
* repo-level config the primary checkout also reads. {@code insteadOf} — not
|
||||||
|
* {@code pushInsteadOf} — because a member may also need to fetch or rebase through its own
|
||||||
|
* token.
|
||||||
|
*
|
||||||
|
* <p>The host (and, for the rewrite's SSH-side match, the port) come from parsing the origin
|
||||||
|
* itself, never a hardcoded forge host. An origin already {@code https://} is left alone; the
|
||||||
|
* credential helper already covers it. An origin that is neither {@code ssh://} nor
|
||||||
|
* {@code https://} — including the scp-like shorthand ({@code git@host:path}, no scheme) — is
|
||||||
|
* left untouched: that shorthand's {@code host:path} split is defined by the user's ssh_config
|
||||||
|
* aliases, not by URI syntax, so guessing at it risks rewriting to the wrong place, and that
|
||||||
|
* origin keeps SSH-only push behaviour instead.
|
||||||
|
*/
|
||||||
private void configureHttpsUrlRewriteForSshOrigin(String repoRoot, String worktreePath) {
|
private void configureHttpsUrlRewriteForSshOrigin(String repoRoot, String worktreePath) {
|
||||||
if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) {
|
if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.LongSupplier;
|
import java.util.function.LongSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authoritative in-daemon registry of the worker sessions this {@code fleetd} process spawned.
|
* Authoritative in-daemon registry of the worker sessions this {@code fleetd} process spawned.
|
||||||
@@ -57,6 +58,18 @@ public final class SessionManager implements TurnListener {
|
|||||||
* Populated on every spawn path, removed on {@link #release}.
|
* Populated on every spawn path, removed on {@link #release}.
|
||||||
*/
|
*/
|
||||||
private final ConcurrentHashMap<String /*paneId*/, PeerHandle> handles = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String /*paneId*/, PeerHandle> handles = new ConcurrentHashMap<>();
|
||||||
|
/**
|
||||||
|
* fleetd #702: a pane mid-teardown, keyed by paneId, held from just before its registry entry
|
||||||
|
* is removed until {@link #releaseRemoved} finishes. {@link #spawnedMemberRole} consults this
|
||||||
|
* alongside the registry, so a caller resolving the pane's terminal during that window still
|
||||||
|
* sees a live member and never falls through to a tab map.
|
||||||
|
*
|
||||||
|
* <p>Depth-counted rather than a plain set: two threads can be tearing down the same pane at
|
||||||
|
* once (the CAS in {@link #releaseIfCurrent} exists for exactly that race), and with a set the
|
||||||
|
* loser's {@code finally} would unmark the pane while the winner is still mid-teardown,
|
||||||
|
* reopening the window this exists to close.
|
||||||
|
*/
|
||||||
|
private final ConcurrentHashMap<String /*paneId*/, Releasing> releasing = new ConcurrentHashMap<>();
|
||||||
private final MemberPresence presence;
|
private final MemberPresence presence;
|
||||||
private final SecureRandom nonceRandom = new SecureRandom();
|
private final SecureRandom nonceRandom = new SecureRandom();
|
||||||
private final AtomicLong nonceSeq = new AtomicLong();
|
private final AtomicLong nonceSeq = new AtomicLong();
|
||||||
@@ -242,6 +255,10 @@ public final class SessionManager implements TurnListener {
|
|||||||
handle.id(), handle.terminalId(), resolvedProfile, actualRole, cwd, ownerTerminal, now, now, 0,
|
handle.id(), handle.terminalId(), resolvedProfile, actualRole, cwd, ownerTerminal, now, now, 0,
|
||||||
MemberSession.State.SPAWNING, null, null, handle.charterReceipt(), handle.agentSessionId());
|
MemberSession.State.SPAWNING, null, null, handle.charterReceipt(), handle.agentSessionId());
|
||||||
registry.put(handle.id(), session);
|
registry.put(handle.id(), session);
|
||||||
|
// A presence contact that already arrived for this terminal found no registry
|
||||||
|
// entry to transition and gave up silently. Retry it now that one exists; remove
|
||||||
|
// this call and such a session stays in SPAWNING even though it is present.
|
||||||
|
reconcilePresence(handle.terminalId());
|
||||||
handles.put(handle.id(), handle);
|
handles.put(handle.id(), handle);
|
||||||
log.debug("acquired session id={} terminal={} profile={} owner={}",
|
log.debug("acquired session id={} terminal={} profile={} owner={}",
|
||||||
handle.id(), handle.terminalId(), session.profile(), session.ownerTerminal());
|
handle.id(), handle.terminalId(), session.profile(), session.ownerTerminal());
|
||||||
@@ -303,9 +320,11 @@ public final class SessionManager implements TurnListener {
|
|||||||
* is a logged path an operator can reclaim, the cost of a deleted one is unrecoverable work.
|
* is a logged path an operator can reclaim, the cost of a deleted one is unrecoverable work.
|
||||||
*/
|
*/
|
||||||
private MemberSession release(String paneId, ReleaseCause cause) {
|
private MemberSession release(String paneId, ReleaseCause cause) {
|
||||||
MemberSession removed = registry.remove(paneId);
|
return releaseWindow(paneId, registry.get(paneId), () -> {
|
||||||
releaseRemoved(paneId, removed, handles.remove(paneId), cause);
|
MemberSession removed = registry.remove(paneId);
|
||||||
return removed;
|
releaseRemoved(paneId, removed, handles.remove(paneId), cause);
|
||||||
|
return removed;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -314,16 +333,85 @@ public final class SessionManager implements TurnListener {
|
|||||||
* DONE record from stopping a worker that delivery has made BUSY.
|
* DONE record from stopping a worker that delivery has made BUSY.
|
||||||
*/
|
*/
|
||||||
private boolean releaseIfCurrent(MemberSession expected, ReleaseCause cause) {
|
private boolean releaseIfCurrent(MemberSession expected, ReleaseCause cause) {
|
||||||
if (!registry.remove(expected.paneId(), expected)) {
|
return releaseWindow(expected.paneId(), expected, () -> {
|
||||||
// A lifecycle transition replaced the record between the caller's check and this remove.
|
if (!registry.remove(expected.paneId(), expected)) {
|
||||||
// Log it: this race is by definition unobservable otherwise, and a reaper that silently
|
// A lifecycle transition replaced the record between the caller's check and this
|
||||||
// declines to reap is the hardest kind of behaviour to diagnose after the fact.
|
// remove. Log it: this race is by definition unobservable otherwise, and a reaper
|
||||||
log.debug("skipping reap of pane={}: its registry record changed after the idle check "
|
// that silently declines to reap is the hardest kind of behaviour to diagnose
|
||||||
+ "(most likely a delivery made it BUSY)", expected.paneId());
|
// after the fact.
|
||||||
return false;
|
log.debug("skipping reap of pane={}: its registry record changed after the idle "
|
||||||
|
+ "check (most likely a delivery made it BUSY)", expected.paneId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
releaseRemoved(expected.paneId(), expected, handles.remove(expected.paneId()), cause);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #702: mark {@code paneId} as mid-teardown — using {@code known}'s terminal/role when
|
||||||
|
* it is available — for the whole of {@code teardown}, which removes the registry entry and
|
||||||
|
* then runs {@link #releaseRemoved}. Shared by both registry-removal sites ({@link #release}'s
|
||||||
|
* unconditional remove and {@link #releaseIfCurrent}'s CAS remove) so neither can leave the
|
||||||
|
* other's window unmarked.
|
||||||
|
*
|
||||||
|
* <p>The mark is written before {@code teardown} runs — so it covers the removal itself, not
|
||||||
|
* only what comes after it — and cleared in a {@code finally}, so an unchecked throw out of
|
||||||
|
* {@code teardown} (including one from {@link PeerLauncher#stop}, which declares nothing) can
|
||||||
|
* never leave the pane marked for the rest of the daemon's life.
|
||||||
|
*/
|
||||||
|
private <T> T releaseWindow(String paneId, MemberSession known, Supplier<T> teardown) {
|
||||||
|
releasing.compute(paneId, (_, prior) -> Releasing.enter(prior, known));
|
||||||
|
try {
|
||||||
|
return teardown.get();
|
||||||
|
} finally {
|
||||||
|
releasing.compute(paneId, (_, prior) -> prior == null ? null : prior.leave());
|
||||||
}
|
}
|
||||||
releaseRemoved(expected.paneId(), expected, handles.remove(expected.paneId()), cause);
|
}
|
||||||
return true;
|
|
||||||
|
/**
|
||||||
|
* Depth count plus the terminal/role a mid-teardown pane belongs to, for
|
||||||
|
* {@link #spawnedMemberRole}. The terminal/role come from whichever call into
|
||||||
|
* {@link #releaseWindow} first knew them: a call that finds the registry entry already gone
|
||||||
|
* passes a {@code null} session, and must not blank out what the first call recorded.
|
||||||
|
*/
|
||||||
|
record Releasing(int depth, String terminalId, MemberRole role) {
|
||||||
|
static Releasing enter(Releasing prior, MemberSession known) {
|
||||||
|
int depth = (prior == null ? 0 : prior.depth()) + 1;
|
||||||
|
String terminalId = known != null ? known.terminalId() : prior == null ? null : prior.terminalId();
|
||||||
|
MemberRole role = known != null ? known.role() : prior == null ? null : prior.role();
|
||||||
|
return new Releasing(depth, terminalId, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
Releasing leave() {
|
||||||
|
return depth <= 1 ? null : new Releasing(depth - 1, terminalId, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role of the live spawned member occupying {@code terminal} — whether it is currently in
|
||||||
|
* the registry, or mid-teardown between {@link #release} removing its registry entry and
|
||||||
|
* {@link #releaseRemoved} actually stopping its pane (fleetd #702). {@code null} for a terminal
|
||||||
|
* that is neither: this method is the one reader a caller resolver consults before any tab
|
||||||
|
* map, so a live or releasing member's identity never falls back to a tab label.
|
||||||
|
*
|
||||||
|
* <p>Checks the registry directly via {@link #findByTerminal} rather than {@link #roster()},
|
||||||
|
* so this hot-path lookup (consulted on every resolve) never pays for a list copy or a stream.
|
||||||
|
*/
|
||||||
|
public MemberRole spawnedMemberRole(String terminal) {
|
||||||
|
MemberSession session = findByTerminal(terminal);
|
||||||
|
if (session != null) {
|
||||||
|
return session.role();
|
||||||
|
}
|
||||||
|
if (terminal == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Releasing r : releasing.values()) {
|
||||||
|
if (terminal.equals(r.terminalId())) {
|
||||||
|
return r.role();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void releaseRemoved(String paneId, MemberSession removed, PeerHandle removedHandle,
|
private void releaseRemoved(String paneId, MemberSession removed, PeerHandle removedHandle,
|
||||||
@@ -382,6 +470,12 @@ public final class SessionManager implements TurnListener {
|
|||||||
MemberSession resolved = resolveAgentSessionId(removed, removedHandle);
|
MemberSession resolved = resolveAgentSessionId(removed, removedHandle);
|
||||||
notifyReleased(new ReleaseDetail(resolved.terminalId(), resolved.worktree(),
|
notifyReleased(new ReleaseDetail(resolved.terminalId(), resolved.worktree(),
|
||||||
resolved.branch(), snapshotRef, resolved.agentSessionId()));
|
resolved.branch(), snapshotRef, resolved.agentSessionId()));
|
||||||
|
String terminal = removed.terminalId();
|
||||||
|
if (terminal != null && !terminal.isBlank()) {
|
||||||
|
// Without this, a terminal stays marked present after its pane is gone, so a
|
||||||
|
// later send to the same id would read as deliverable instead of refused.
|
||||||
|
presence.forget(terminal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// CB-581: the pane must always stop, even if the dirty check above threw. A session removed
|
// CB-581: the pane must always stop, even if the dirty check above threw. A session removed
|
||||||
@@ -725,6 +819,10 @@ public final class SessionManager implements TurnListener {
|
|||||||
handle.charterReceipt(),
|
handle.charterReceipt(),
|
||||||
handle.agentSessionId());
|
handle.agentSessionId());
|
||||||
registry.put(handle.id(), session);
|
registry.put(handle.id(), session);
|
||||||
|
// A presence contact that already arrived for this terminal found no registry entry to
|
||||||
|
// transition and gave up silently. Retry it now that one exists; remove this call and
|
||||||
|
// such a session stays in SPAWNING even though it is present.
|
||||||
|
reconcilePresence(handle.terminalId());
|
||||||
handles.put(handle.id(), handle);
|
handles.put(handle.id(), handle);
|
||||||
log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
|
log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
|
||||||
handle.id(), handle.terminalId(), session.profile(), session.branch(), session.worktree());
|
handle.id(), handle.terminalId(), session.profile(), session.branch(), session.worktree());
|
||||||
@@ -899,6 +997,20 @@ public final class SessionManager implements TurnListener {
|
|||||||
transitionByTerminal(terminalId, MemberSession.State.SPAWNING, MemberSession.State.READY);
|
transitionByTerminal(terminalId, MemberSession.State.SPAWNING, MemberSession.State.READY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes a newly registered session's {@code SPAWNING -> READY} transition when {@code
|
||||||
|
* terminalId} was already marked present before this ran. A terminal never marked present is
|
||||||
|
* left in {@code SPAWNING}; it reaches {@code READY} normally through {@link #onReady} once
|
||||||
|
* its own contact arrives. Callers must run this only once the session's registry entry is
|
||||||
|
* already visible — {@link #onReady}'s transition matches against that entry, and reconciling
|
||||||
|
* before the entry exists finds nothing to transition.
|
||||||
|
*/
|
||||||
|
private void reconcilePresence(String terminalId) {
|
||||||
|
if (terminalId != null && !terminalId.isBlank() && presence.isPresent(terminalId)) {
|
||||||
|
onReady(terminalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle hook: a message was delivered into the worker — it is now busy on a turn.
|
* Lifecycle hook: a message was delivered into the worker — it is now busy on a turn.
|
||||||
* The turn count is bumped and the activity timestamp is refreshed. A {@code DONE} session
|
* The turn count is bumped and the activity timestamp is refreshed. A {@code DONE} session
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* CB-534: the injector's readiness gate must open for a lead as well as for a present worker.
|
* CB-534: the injector's readiness gate must open for a lead as well as for a present worker.
|
||||||
|
* fleetd #669 follow-up: the same gate must also open for a collaborator, which — like a lead —
|
||||||
|
* is never enrolled in {@link MemberPresence} and never discovered by the lead scan.
|
||||||
*
|
*
|
||||||
* <p>The bug these cover was silent and slow: a lead was never marked present (only workers are), so
|
* <p>The bug these cover was silent and slow: a lead (and later a collaborator) was never marked
|
||||||
* every lead→lead delivery sat on the gate for the full readiness grace and failed ~60s later without
|
* present (only workers are) and never counted as a lead, so every send to one sat on the gate for
|
||||||
* a keystroke ever reaching the pane.
|
* the full readiness grace and failed ~60s later without a keystroke ever reaching the pane.
|
||||||
*/
|
*/
|
||||||
class FleetDeliverabilityTest {
|
class FleetDeliverabilityTest {
|
||||||
|
|
||||||
@@ -25,19 +27,25 @@ class FleetDeliverabilityTest {
|
|||||||
return () -> m;
|
return () -> m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Supplier<Map<String, String>> collaborators(Map<String, String> m) {
|
||||||
|
return () -> m;
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("a worker that has connected its MCP is deliverable")
|
@DisplayName("a worker that has connected its MCP is deliverable")
|
||||||
void presentWorkerIsDeliverable() {
|
void presentWorkerIsDeliverable() {
|
||||||
MemberPresence presence = new MemberPresence();
|
MemberPresence presence = new MemberPresence();
|
||||||
presence.markPresent("term_worker");
|
presence.markPresent("term_worker");
|
||||||
|
|
||||||
assertTrue(Fleetd.deliverableTo(presence, leads(Map.of())).test("term_worker"));
|
assertTrue(Fleetd.deliverableTo(presence, leads(Map.of()), collaborators(Map.of()))
|
||||||
|
.test("term_worker"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("a worker still in its boot window is held back")
|
@DisplayName("a worker still in its boot window is held back")
|
||||||
void absentWorkerIsNotDeliverable() {
|
void absentWorkerIsNotDeliverable() {
|
||||||
assertFalse(Fleetd.deliverableTo(new MemberPresence(), leads(Map.of())).test("term_booting"));
|
assertFalse(Fleetd.deliverableTo(new MemberPresence(), leads(Map.of()), collaborators(Map.of()))
|
||||||
|
.test("term_booting"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -45,19 +53,31 @@ class FleetDeliverabilityTest {
|
|||||||
void leadIsDeliverableWithoutPresence() {
|
void leadIsDeliverableWithoutPresence() {
|
||||||
MemberPresence presence = new MemberPresence();
|
MemberPresence presence = new MemberPresence();
|
||||||
Predicate<String> deliverable =
|
Predicate<String> deliverable =
|
||||||
Fleetd.deliverableTo(presence, leads(Map.of("term_lead", "opus-5.0")));
|
Fleetd.deliverableTo(presence, leads(Map.of("term_lead", "opus-5.0")), collaborators(Map.of()));
|
||||||
|
|
||||||
assertFalse(presence.isPresent("term_lead"), "a lead is never enrolled in worker presence");
|
assertFalse(presence.isPresent("term_lead"), "a lead is never enrolled in worker presence");
|
||||||
assertTrue(deliverable.test("term_lead"), "…and must be deliverable anyway");
|
assertTrue(deliverable.test("term_lead"), "…and must be deliverable anyway");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("an unknown terminal is deliverable to neither")
|
@DisplayName("a collaborator is deliverable without ever being marked present or scanned as a lead")
|
||||||
|
void collaboratorIsDeliverableWithoutPresenceOrLeadStatus() {
|
||||||
|
MemberPresence presence = new MemberPresence();
|
||||||
|
Predicate<String> deliverable = Fleetd.deliverableTo(presence, leads(Map.of()),
|
||||||
|
collaborators(Map.of("term_collab", "kevin")));
|
||||||
|
|
||||||
|
assertFalse(presence.isPresent("term_collab"), "a collaborator is never enrolled in worker presence");
|
||||||
|
assertTrue(deliverable.test("term_collab"), "…and must be deliverable anyway");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("an unknown terminal is deliverable to none of presence, leads, or collaborators")
|
||||||
void strangerIsNotDeliverable() {
|
void strangerIsNotDeliverable() {
|
||||||
MemberPresence presence = new MemberPresence();
|
MemberPresence presence = new MemberPresence();
|
||||||
presence.markPresent("term_worker");
|
presence.markPresent("term_worker");
|
||||||
|
|
||||||
assertFalse(Fleetd.deliverableTo(presence, leads(Map.of("term_lead", "opus-5.0")))
|
assertFalse(Fleetd.deliverableTo(presence, leads(Map.of("term_lead", "opus-5.0")),
|
||||||
|
collaborators(Map.of("term_collab", "kevin")))
|
||||||
.test("term_stranger"));
|
.test("term_stranger"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,22 +85,47 @@ class FleetDeliverabilityTest {
|
|||||||
@DisplayName("a lead discovered after startup becomes deliverable with no restart")
|
@DisplayName("a lead discovered after startup becomes deliverable with no restart")
|
||||||
void leadSetIsReadThroughOnEveryCall() {
|
void leadSetIsReadThroughOnEveryCall() {
|
||||||
Map<String, String> discovered = new HashMap<>();
|
Map<String, String> discovered = new HashMap<>();
|
||||||
Predicate<String> deliverable = Fleetd.deliverableTo(new MemberPresence(), leads(discovered));
|
Predicate<String> deliverable =
|
||||||
|
Fleetd.deliverableTo(new MemberPresence(), leads(discovered), collaborators(Map.of()));
|
||||||
|
|
||||||
assertFalse(deliverable.test("term_late"));
|
assertFalse(deliverable.test("term_late"));
|
||||||
discovered.put("term_late", "gpt-sol-5.6"); // leadScan picks up a newly labelled tab
|
discovered.put("term_late", "gpt-sol-5.6"); // leadScan picks up a newly labelled tab
|
||||||
assertTrue(deliverable.test("term_late"), "the supplier must be re-read, not snapshotted");
|
assertTrue(deliverable.test("term_late"), "the supplier must be re-read, not snapshotted");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a collaborator discovered after startup becomes deliverable with no restart")
|
||||||
|
void collaboratorSetIsReadThroughOnEveryCall() {
|
||||||
|
Map<String, String> discovered = new HashMap<>();
|
||||||
|
Predicate<String> deliverable =
|
||||||
|
Fleetd.deliverableTo(new MemberPresence(), leads(Map.of()), collaborators(discovered));
|
||||||
|
|
||||||
|
assertFalse(deliverable.test("term_late_collab"));
|
||||||
|
discovered.put("term_late_collab", "kevin"); // the same tab scan picks up a newly labelled collaborator tab
|
||||||
|
assertTrue(deliverable.test("term_late_collab"), "the supplier must be re-read, not snapshotted");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("forgetting a torn-down worker does not strip a lead of its deliverability")
|
@DisplayName("forgetting a torn-down worker does not strip a lead of its deliverability")
|
||||||
void forgetDoesNotDisarmALead() {
|
void forgetDoesNotDisarmALead() {
|
||||||
MemberPresence presence = new MemberPresence();
|
MemberPresence presence = new MemberPresence();
|
||||||
Predicate<String> deliverable =
|
Predicate<String> deliverable =
|
||||||
Fleetd.deliverableTo(presence, leads(Map.of("term_lead", "opus-5.0")));
|
Fleetd.deliverableTo(presence, leads(Map.of("term_lead", "opus-5.0")), collaborators(Map.of()));
|
||||||
|
|
||||||
presence.forget("term_lead"); // the injector's cleanup path runs against every target
|
presence.forget("term_lead"); // the injector's cleanup path runs against every target
|
||||||
|
|
||||||
assertTrue(deliverable.test("term_lead"));
|
assertTrue(deliverable.test("term_lead"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("forgetting a torn-down worker does not strip a collaborator of its deliverability")
|
||||||
|
void forgetDoesNotDisarmACollaborator() {
|
||||||
|
MemberPresence presence = new MemberPresence();
|
||||||
|
Predicate<String> deliverable = Fleetd.deliverableTo(presence, leads(Map.of()),
|
||||||
|
collaborators(Map.of("term_collab", "kevin")));
|
||||||
|
|
||||||
|
presence.forget("term_collab"); // the injector's cleanup path runs against every target
|
||||||
|
|
||||||
|
assertTrue(deliverable.test("term_collab"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,25 +29,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fleetd #672 — pins the {@link FleetMcp.AuthorizationMode} that {@link FleetdAssembly}'s
|
* Asserts that the {@link FleetMcp} built by {@link FleetdAssembly#assembleAndStart} applies the
|
||||||
* production boot path passes to {@link FleetMcp} at {@code FleetdAssembly.java:481}
|
* authorization table: a worker is refused {@code SPAWN}, and the primary is allowed it.
|
||||||
* ({@code AuthorizationMode.ENFORCED}).
|
|
||||||
*
|
|
||||||
* <p>{@code FleetMcpAuthzTest} already exercises {@code FleetMcp#denyFor} against the CB-505
|
|
||||||
* table, but it constructs its own {@link FleetMcp} and chooses its own {@code AuthorizationMode}
|
|
||||||
* — it tests the seam, not the producer. This test instead reaches the exact {@link FleetMcp}
|
|
||||||
* {@link FleetdAssembly#assembleAndStart} builds (via {@code FleetdRuntime#mcp()}, the same
|
|
||||||
* accessor {@code FleetdAssemblyConnectionIdentityTest} uses for {@code identity()}) and asserts
|
|
||||||
* the consequence rather than reading the enum back: an unauthorized caller must actually be
|
|
||||||
* refused through it, and the primary must still be allowed, so the test cannot pass with the
|
|
||||||
* gate wired backwards.
|
|
||||||
*
|
|
||||||
* <p>{@code FleetMcp#denyFor} is package-private to {@code dev.ltms.fleet.mcp}; this test lives in
|
|
||||||
* {@code dev.ltms.fleet}, where {@link FleetdAssembly} and {@code FleetdRuntime#mcp()} live, so it
|
|
||||||
* cannot call {@code denyFor} directly. Reflection bridges that package boundary the same way
|
|
||||||
* {@code getDeclaredField} does in {@link FleetdAssemblyLeadTabScannerExclusionTest} — the
|
|
||||||
* assertion itself still exercises the real policy decision ({@code denyFor} calling
|
|
||||||
* {@code Authz.permits}), not a field read.
|
|
||||||
*/
|
*/
|
||||||
class FleetdAssemblyAuthorizationModeTest {
|
class FleetdAssemblyAuthorizationModeTest {
|
||||||
|
|
||||||
@@ -106,7 +89,7 @@ class FleetdAssemblyAuthorizationModeTest {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void startHttp(Javalin app, String host, int port) {
|
public void startHttp(Javalin app, String host, int port) {
|
||||||
// Do not bind a real port in this assembly test — see FleetdAssemblyLeadTabScannerExclusionTest.
|
// Binding a real port would clash with any daemon already listening on it.
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -151,11 +134,10 @@ class FleetdAssemblyAuthorizationModeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invokes the real production {@code FleetMcp#denyFor} by reflection. The method is
|
* Invokes {@code FleetMcp#denyFor}, which is package-private to {@code dev.ltms.fleet.mcp}
|
||||||
* package-private to {@code dev.ltms.fleet.mcp}; this is the only seam available to this test
|
* while this test is in {@code dev.ltms.fleet}. Nothing here catches a missing method: if
|
||||||
* without a full HTTP/servlet round trip (see this class's javadoc). No {@code catch} here can
|
* {@code denyFor} is renamed or removed, {@link NoSuchMethodException} propagates and the
|
||||||
* turn a missing method into a pass — a {@code NoSuchMethodException} propagates out of the
|
* test fails.
|
||||||
* test and fails it loudly if {@code denyFor} is ever renamed or removed.
|
|
||||||
*/
|
*/
|
||||||
private static McpSchema.CallToolResult denyFor(FleetMcp mcp, Principal caller, Authz.Action action,
|
private static McpSchema.CallToolResult denyFor(FleetMcp mcp, Principal caller, Authz.Action action,
|
||||||
String target) throws Exception {
|
String target) throws Exception {
|
||||||
@@ -172,8 +154,7 @@ class FleetdAssemblyAuthorizationModeTest {
|
|||||||
McpSchema.CallToolResult deniedForWorker = denyFor(mcp, Principal.worker("term_a", 200),
|
McpSchema.CallToolResult deniedForWorker = denyFor(mcp, Principal.worker("term_a", 200),
|
||||||
Authz.Action.SPAWN, "term_a");
|
Authz.Action.SPAWN, "term_a");
|
||||||
assertNotNull(deniedForWorker,
|
assertNotNull(deniedForWorker,
|
||||||
"FleetdAssembly.java:481 must pass AuthorizationMode.ENFORCED to FleetMcp — a worker "
|
"a worker must not be able to fleet_spawn through the assembled FleetMcp");
|
||||||
+ "must not be able to fleet_spawn through the assembled production object");
|
|
||||||
assertTrue(deniedForWorker.isError(), "a refusal is returned as an MCP tool error");
|
assertTrue(deniedForWorker.isError(), "a refusal is returned as an MCP tool error");
|
||||||
|
|
||||||
McpSchema.CallToolResult allowedForPrimary = denyFor(mcp, Principal.primary(100),
|
McpSchema.CallToolResult allowedForPrimary = denyFor(mcp, Principal.primary(100),
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package dev.ltms.fleet;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.config.ConfigRef;
|
||||||
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
|
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||||
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
|
import dev.ltms.fleet.msg.ReplyInbox;
|
||||||
|
import io.javalin.Javalin;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit E. Reaches the real {@link dev.ltms.fleet.herdr.HerdrRouter} that {@link
|
||||||
|
* FleetdAssembly#assembleAndStart} builds and wires — not a copy built for this test — and proves
|
||||||
|
* that a configured collaborator's terminal routes to the LEAD herdr daemon.
|
||||||
|
*
|
||||||
|
* <p>Two distinct {@link FakeHerdr} instances are required, the same pattern {@code
|
||||||
|
* FleetdAssemblyConnectionIdentityTest} and {@code FleetdLeadRolloverAssemblyTest} already use:
|
||||||
|
* with one client shared between {@code herdrSocket} and {@code memberHerdrSocket},
|
||||||
|
* {@code HerdrRouter} folds {@code leadAgents} and {@code memberAgents} into the same instance
|
||||||
|
* (see its constructor), and {@code agentsFor} would return that one object regardless of whether
|
||||||
|
* the collaborator map was ever consulted — invisible to a mutation of the predicate this ticket
|
||||||
|
* fixes. This test's two sockets resolve to two different fakes, so the assertion only passes when
|
||||||
|
* the collaborator's terminal is actually recognised and routed to the lead one.
|
||||||
|
*/
|
||||||
|
class FleetdAssemblyCollaboratorHerdrRoutingTest {
|
||||||
|
|
||||||
|
private static final Path LEAD_SOCKET = Path.of("/fake/lead-herdr.sock");
|
||||||
|
private static final Path MEMBER_SOCKET = Path.of("/fake/member-herdr.sock");
|
||||||
|
|
||||||
|
private static final class RecordingResourcePorts implements ResourcePorts {
|
||||||
|
final Map<Path, HerdrClient> herdrsBySocket = new LinkedHashMap<>();
|
||||||
|
Runnable shutdownHook;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String> environment() {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HerdrClient connectHerdr(Path socketPath) {
|
||||||
|
HerdrClient client = herdrsBySocket.get(socketPath);
|
||||||
|
if (client == null) {
|
||||||
|
throw new IllegalStateException("no fake herdr registered for socket " + socketPath);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.AmqpOpener replyInboxOpener() {
|
||||||
|
return (uri, prefetch) -> new ReplyInbox() {
|
||||||
|
@Override public void own(String target) { }
|
||||||
|
@Override public void release(String target) { }
|
||||||
|
@Override public void publish(String target, String msgId, String content) { }
|
||||||
|
@Override public List<InboxMessage> peek(String target) { return List.of(); }
|
||||||
|
@Override public boolean ack(String target, String msgId) { return false; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.LeadMailboxOpener leadMailboxOpener() {
|
||||||
|
return (uri, selfCoordId, prefetch) -> {
|
||||||
|
throw new UnsupportedOperationException(
|
||||||
|
"leadMailboxOpener must not be called — no coordinator: block is configured");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier nanoClock() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier wallClockNanos() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScheduledExecutorService newScheduler(String purpose) {
|
||||||
|
return Executors.newSingleThreadScheduledExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addShutdownHook(Runnable hook) {
|
||||||
|
shutdownHook = hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startHttp(Javalin app, String host, int port) {
|
||||||
|
// Do not bind a real port in this assembly test.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Runnable herdrPollWait() {
|
||||||
|
return () -> {
|
||||||
|
throw new UnsupportedOperationException("FakeHerdr is healthy; no poll wait is expected");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FleetConfig writeConfig(Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(file, """
|
||||||
|
bind:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8765
|
||||||
|
herdrSocket: "%s"
|
||||||
|
memberHerdrSocket: "%s"
|
||||||
|
idleSleepGuard:
|
||||||
|
enabled: false
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
reviewer-alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
profiles:
|
||||||
|
sonnet:
|
||||||
|
subscription: true
|
||||||
|
argv: ["ccs", "sonnet"]
|
||||||
|
""".formatted(LEAD_SOCKET, MEMBER_SOCKET));
|
||||||
|
return FleetConfig.load(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void assembledRouterRoutesACollaboratorTerminalToTheLeadDaemon(@TempDir Path dir) throws Exception {
|
||||||
|
FleetConfig cfg = writeConfig(dir);
|
||||||
|
RecordingResourcePorts ports = new RecordingResourcePorts();
|
||||||
|
|
||||||
|
// The fixed FakeHerdr fixture already ties terminal "term_a" to a live agent on tab
|
||||||
|
// "w2:t7" (pane "w2:p7") — seeding only the tab LABEL to match the configured collaborator
|
||||||
|
// is enough to make LeadTabScanner resolve "term_a" as that collaborator. Seeded on the
|
||||||
|
// LEAD fake only: a collaborator's pane lives in the lead daemon, exactly like a lead's.
|
||||||
|
FakeHerdr lead = new FakeHerdr().withTab("w2", "w2:t7", "collab: alex");
|
||||||
|
FakeHerdr member = new FakeHerdr();
|
||||||
|
ports.herdrsBySocket.put(LEAD_SOCKET, lead);
|
||||||
|
ports.herdrsBySocket.put(MEMBER_SOCKET, member);
|
||||||
|
|
||||||
|
FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg,
|
||||||
|
new ConfigRef(dir.resolve("fleetd.yaml"), cfg), new SubscriptionGuard(cfg.guard().hostSet())), ports);
|
||||||
|
try {
|
||||||
|
assertSame(runtime.router().leadAgents(), runtime.router().agentsFor("term_a"),
|
||||||
|
"a configured collaborator's terminal must route to the LEAD daemon — "
|
||||||
|
+ "FleetdAssembly must wire the collaborator map into the router's "
|
||||||
|
+ "predicate, not just LeadTabScanner.get()");
|
||||||
|
assertSame(runtime.router().memberAgents(), runtime.router().agentsFor("term_shell"),
|
||||||
|
"control: a terminal naming neither a lead nor a collaborator (term_shell, on "
|
||||||
|
+ "the unlabelled tab w2:t8) must still route to the member daemon");
|
||||||
|
} finally {
|
||||||
|
assertNotNull(ports.shutdownHook, "control: assembly must capture its shutdown hook");
|
||||||
|
ports.shutdownHook.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* fleetd #670 — pins the {@code excludedWorkspaceLabels} argument {@link FleetdAssembly}'s
|
* fleetd #670 — pins the {@code excludedWorkspaceLabels} argument {@link FleetdAssembly}'s
|
||||||
* production boot path passes to {@link LeadTabScanner} at {@code FleetdAssembly.java:265}
|
* production boot path passes to {@link LeadTabScanner} ({@code Set.of()}).
|
||||||
* ({@code Set.of()}).
|
|
||||||
*
|
*
|
||||||
* <p>{@code LeadTabScannerTest} already covers this constructor parameter, but it builds its own
|
* <p>{@code LeadTabScannerTest} already covers this constructor parameter, but it builds its own
|
||||||
* {@link LeadTabScanner} with its own set, so it tests the seam and proves nothing about the
|
* {@link LeadTabScanner} with its own set, so it tests the seam and proves nothing about the
|
||||||
@@ -191,7 +190,7 @@ class FleetdAssemblyLeadTabScannerExclusionTest {
|
|||||||
Set<?> excluded = (Set<?>) excludedField.get(leads);
|
Set<?> excluded = (Set<?>) excludedField.get(leads);
|
||||||
|
|
||||||
assertTrue(excluded.isEmpty(),
|
assertTrue(excluded.isEmpty(),
|
||||||
"FleetdAssembly.java:265 must pass an empty excludedWorkspaceLabels to "
|
"FleetdAssembly must pass an empty excludedWorkspaceLabels to "
|
||||||
+ "LeadTabScanner — scanning member tabs would demote the lead to a worker");
|
+ "LeadTabScanner — scanning member tabs would demote the lead to a worker");
|
||||||
} finally {
|
} finally {
|
||||||
assertNotNull(ports.shutdownHook, "control: assembly must capture its shutdown hook");
|
assertNotNull(ports.shutdownHook, "control: assembly must capture its shutdown hook");
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package dev.ltms.fleet;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.config.ConfigRef;
|
||||||
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
|
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||||
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
|
import dev.ltms.fleet.inject.Injector;
|
||||||
|
import dev.ltms.fleet.inject.StatusPoller;
|
||||||
|
import dev.ltms.fleet.msg.LeadChannelHandle;
|
||||||
|
import dev.ltms.fleet.msg.LeadCoordLoop;
|
||||||
|
import dev.ltms.fleet.msg.LeadMessage;
|
||||||
|
import dev.ltms.fleet.msg.ReplyInbox;
|
||||||
|
import dev.ltms.fleet.msg.ReplyPushLoop;
|
||||||
|
import io.javalin.Javalin;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the assembled loops use the production reminder, coordination, and delivery timing
|
||||||
|
* defaults when no {@code primary:} block configures the reply-push values.
|
||||||
|
*/
|
||||||
|
class FleetdAssemblyTimingDefaultsTest {
|
||||||
|
|
||||||
|
private static final class FakeLeadChannel implements LeadChannelHandle {
|
||||||
|
@Override
|
||||||
|
public void publish(String toCoordId, LeadMessage message) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<LeadMessage> peek() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void ack(String msgId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String selfCoordId() {
|
||||||
|
return "test-lead";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean heldDurable() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MailboxState inspect(String coordId) {
|
||||||
|
return MailboxState.unknown(coordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class TestResourcePorts implements ResourcePorts {
|
||||||
|
final FakeHerdr herdr = new FakeHerdr();
|
||||||
|
Runnable shutdownHook;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String> environment() {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HerdrClient connectHerdr(Path socketPath) {
|
||||||
|
return herdr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.AmqpOpener replyInboxOpener() {
|
||||||
|
return (uri, prefetch) -> new ReplyInbox() {
|
||||||
|
@Override public void own(String target) { }
|
||||||
|
@Override public void release(String target) { }
|
||||||
|
@Override public void publish(String target, String msgId, String content) { }
|
||||||
|
@Override public List<InboxMessage> peek(String target) { return List.of(); }
|
||||||
|
@Override public boolean ack(String target, String msgId) { return false; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.LeadMailboxOpener leadMailboxOpener() {
|
||||||
|
return (uri, selfCoordId, prefetch) -> new FakeLeadChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier nanoClock() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier wallClockNanos() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScheduledExecutorService newScheduler(String purpose) {
|
||||||
|
return Executors.newSingleThreadScheduledExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addShutdownHook(Runnable hook) {
|
||||||
|
shutdownHook = hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startHttp(Javalin app, String host, int port) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Runnable herdrPollWait() {
|
||||||
|
return () -> {
|
||||||
|
throw new UnsupportedOperationException("FakeHerdr is healthy; no poll wait is expected");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TestResourcePorts ports;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (ports != null && ports.shutdownHook != null) {
|
||||||
|
ports.shutdownHook.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FleetConfig writeConfig(Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(file, """
|
||||||
|
bind:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8765
|
||||||
|
idleSleepGuard:
|
||||||
|
enabled: false
|
||||||
|
coordinator:
|
||||||
|
uri: "amqp://fake-lead-broker/vh"
|
||||||
|
selfId: "test-lead"
|
||||||
|
""");
|
||||||
|
return FleetConfig.load(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FleetdRuntime assemble(Path dir) throws Exception {
|
||||||
|
FleetConfig cfg = writeConfig(dir);
|
||||||
|
ports = new TestResourcePorts();
|
||||||
|
return FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg,
|
||||||
|
new ConfigRef(dir.resolve("fleetd.yaml"), cfg), new SubscriptionGuard(cfg.guard().hostSet())), ports);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long longField(Object target, String name) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field.getLong(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void productionBootPathUsesTheExpectedLoopTimingDefaults(@TempDir Path dir) throws Exception {
|
||||||
|
FleetdRuntime runtime = assemble(dir);
|
||||||
|
|
||||||
|
ReplyPushLoop pushLoop = runtime.pushLoop();
|
||||||
|
assertEquals(5, longField(pushLoop, "maxReminders"),
|
||||||
|
"without primary:, ReplyPushLoop must stop after five reminder attempts");
|
||||||
|
assertEquals(15_000L, longField(pushLoop, "backoffMs"),
|
||||||
|
"without primary:, ReplyPushLoop must wait fifteen seconds before the next reminder");
|
||||||
|
|
||||||
|
LeadCoordLoop leadCoordLoop = runtime.leadCoordLoop();
|
||||||
|
assertNotNull(leadCoordLoop, "control: coordinator: must build LeadCoordLoop");
|
||||||
|
assertEquals(3_000L, longField(leadCoordLoop, "intervalMs"),
|
||||||
|
"LeadCoordLoop must poll for peer-lead mail every three seconds");
|
||||||
|
|
||||||
|
StatusPoller poller = runtime.poller();
|
||||||
|
assertEquals(Injector.POLL_INTERVAL_MILLIS, longField(poller, "intervalMillis"),
|
||||||
|
"StatusPoller must use Injector's delivery poll interval");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,6 +125,7 @@ class FleetdAssemblyTurnRegistrarBehaviouralTest {
|
|||||||
primary:
|
primary:
|
||||||
tab: "lead: primary"
|
tab: "lead: primary"
|
||||||
profile: sonnet
|
profile: sonnet
|
||||||
|
workspace: "ltms"
|
||||||
profiles:
|
profiles:
|
||||||
sonnet:
|
sonnet:
|
||||||
subscription: true
|
subscription: true
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ class FleetdBackendErrorSinkTest {
|
|||||||
return MAPPER.createObjectNode().set("agent", MAPPER.createObjectNode()
|
return MAPPER.createObjectNode().set("agent", MAPPER.createObjectNode()
|
||||||
.put("terminal_id", "term_primary").put("agent_status", "idle"));
|
.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)) {
|
if ("agent.prompt".equals(method)) {
|
||||||
prompts.add(params);
|
prompts.add(params);
|
||||||
sendLatch.countDown();
|
sendLatch.countDown();
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ class FleetdLeadContextSourceWindowAssemblyTest {
|
|||||||
%s:
|
%s:
|
||||||
tab: "%s"
|
tab: "%s"
|
||||||
profile: %s
|
profile: %s
|
||||||
|
workspace: "ltms"
|
||||||
profiles:
|
profiles:
|
||||||
%s:
|
%s:
|
||||||
subscription: true
|
subscription: true
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import dev.ltms.fleet.guard.SubscriptionGuard;
|
|||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.FakeHerdr;
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
import dev.ltms.fleet.herdr.HerdrClient;
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
|
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||||
|
import dev.ltms.fleet.lead.LeadLauncher;
|
||||||
import dev.ltms.fleet.lead.LeadRollover;
|
import dev.ltms.fleet.lead.LeadRollover;
|
||||||
import dev.ltms.fleet.msg.ReplyInbox;
|
import dev.ltms.fleet.msg.ReplyInbox;
|
||||||
import io.javalin.Javalin;
|
import io.javalin.Javalin;
|
||||||
@@ -34,7 +36,7 @@ import static org.junit.jupiter.api.Assertions.fail;
|
|||||||
* fleetd #612 Unit A) with three methods: {@code unrelatedAnchorStillPresent} (a scaffold anchor,
|
* fleetd #612 Unit A) with three methods: {@code unrelatedAnchorStillPresent} (a scaffold anchor,
|
||||||
* not an independent claim — needs no replacement of its own), {@code
|
* not an independent claim — needs no replacement of its own), {@code
|
||||||
* mainStillCallsTheLeadRolloverFactory} (the call-site pin replaced by {@link
|
* mainStillCallsTheLeadRolloverFactory} (the call-site pin replaced by {@link
|
||||||
* #assembledLeadRolloverRunsTheRealClearAndBootstrapSequence}), and {@code
|
* #assembledLeadRolloverEndsTheOldPaneThroughTheRealHerdrRouter}), and {@code
|
||||||
* factoryGatesOnConfigPresence} (the absent-config claim replaced by {@link
|
* factoryGatesOnConfigPresence} (the absent-config claim replaced by {@link
|
||||||
* #absentLeadRolloverConfigMeansNoRolloverIsBuilt} — a claim this ticket found was NOT actually
|
* #absentLeadRolloverConfigMeansNoRolloverIsBuilt} — a claim this ticket found was NOT actually
|
||||||
* covered behaviourally anywhere else: {@code LeadRolloverTest}'s only related assertion is
|
* covered behaviourally anywhere else: {@code LeadRolloverTest}'s only related assertion is
|
||||||
@@ -50,8 +52,8 @@ import static org.junit.jupiter.api.Assertions.fail;
|
|||||||
* invisible to this test, even though the two are genuinely different daemons in production. This
|
* invisible to this test, even though the two are genuinely different daemons in production. This
|
||||||
* version configures two distinct sockets and two distinct {@link FakeHerdr} instances (the same
|
* version configures two distinct sockets and two distinct {@link FakeHerdr} instances (the same
|
||||||
* pattern {@code FleetdAssemblyConnectionIdentityTest}, fleetd #612 B2, already uses to separate
|
* pattern {@code FleetdAssemblyConnectionIdentityTest}, fleetd #612 B2, already uses to separate
|
||||||
* lead from member) and asserts the roll's {@code /clear}/bootstrap sends land on the LEAD fake
|
* lead from member) and asserts the roll's {@code pane.close} call lands on the LEAD fake and
|
||||||
* and never on the MEMBER one.
|
* never on the MEMBER one.
|
||||||
*/
|
*/
|
||||||
class FleetdLeadRolloverAssemblyTest {
|
class FleetdLeadRolloverAssemblyTest {
|
||||||
|
|
||||||
@@ -170,6 +172,7 @@ class FleetdLeadRolloverAssemblyTest {
|
|||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "lead: opus"
|
||||||
cwd: "%s"
|
cwd: "%s"
|
||||||
|
workspace: "ltms"
|
||||||
leadRollover:
|
leadRollover:
|
||||||
handoverPath: handover.md
|
handoverPath: handover.md
|
||||||
requireOperatorConfirm: false
|
requireOperatorConfirm: false
|
||||||
@@ -177,11 +180,48 @@ class FleetdLeadRolloverAssemblyTest {
|
|||||||
return FleetConfig.load(f);
|
return FleetConfig.load(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
/**
|
||||||
|
* Unlike {@link #writeConfig}, this names a {@code profile:} for the lead and declares it
|
||||||
|
* under {@code profiles:}, so {@code LeadLauncher#relaunch} can actually start a fresh agent
|
||||||
|
* instead of refusing with "names no profile". {@code relaunchReadySeconds} is cut to 2s so
|
||||||
|
* the recognition wait (expected to time out — see the test) does not cost real test seconds.
|
||||||
|
*/
|
||||||
|
private static FleetConfig writeConfigWithRelaunchableLead(Path dir, Path leadCwd) throws Exception {
|
||||||
|
Path f = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8765
|
||||||
|
herdrSocket: "%s"
|
||||||
|
memberHerdrSocket: "%s"
|
||||||
|
idleSleepGuard:
|
||||||
|
enabled: false
|
||||||
|
broker:
|
||||||
|
uri: "amqp://fake-test-broker/vh"
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "lead: opus"
|
||||||
|
cwd: "%s"
|
||||||
|
profile: opus
|
||||||
|
workspace: "ltms"
|
||||||
|
profiles:
|
||||||
|
opus:
|
||||||
|
subscription: true
|
||||||
|
argv: ["ccs", "opus"]
|
||||||
|
leadRollover:
|
||||||
|
handoverPath: handover.md
|
||||||
|
requireOperatorConfirm: false
|
||||||
|
relaunchReadySeconds: 2
|
||||||
|
""".formatted(LEAD_SOCKET, MEMBER_SOCKET, leadCwd.toString()));
|
||||||
|
return FleetConfig.load(f);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("[BEHAVIOURAL] the real assembled LeadRollover runs the full open/confirm/continuation "
|
@DisplayName("[BEHAVIOURAL] the real assembled LeadRollover runs the open/confirm/continuation "
|
||||||
+ "sequence — /clear, then bootstrapText — through the real herdr router")
|
+ "sequence through the real herdr router — ending the old pane, then giving up once it "
|
||||||
void assembledLeadRolloverRunsTheRealClearAndBootstrapSequence(@TempDir Path dir) throws Exception {
|
+ "never reports gone")
|
||||||
|
void assembledLeadRolloverEndsTheOldPaneThroughTheRealHerdrRouter(@TempDir Path dir) throws Exception {
|
||||||
Path leadCwd = dir.resolve("lead-workspace");
|
Path leadCwd = dir.resolve("lead-workspace");
|
||||||
Files.createDirectories(leadCwd);
|
Files.createDirectories(leadCwd);
|
||||||
FleetConfig cfg = writeConfig(dir, leadCwd);
|
FleetConfig cfg = writeConfig(dir, leadCwd);
|
||||||
@@ -204,6 +244,11 @@ class FleetdLeadRolloverAssemblyTest {
|
|||||||
+ "Fleetd.leadRollover(...) call site — a mutation to `LeadRollover leadRollover = "
|
+ "Fleetd.leadRollover(...) call site — a mutation to `LeadRollover leadRollover = "
|
||||||
+ "null;` at that call site can never pass this");
|
+ "null;` at that call site can never pass this");
|
||||||
|
|
||||||
|
// assembleAndStart's own boot work (the orphan-worker reap) makes a real call on the
|
||||||
|
// member daemon before the roll ever starts. Clear it here so the assertion below measures
|
||||||
|
// only what the roll itself does, not what daemon startup does.
|
||||||
|
member.calls.clear();
|
||||||
|
|
||||||
LeadRollover.PendingRollover pending = rollover.open("term_a", "fleetd #612 B3 test");
|
LeadRollover.PendingRollover pending = rollover.open("term_a", "fleetd #612 B3 test");
|
||||||
String expectedHandoverPath = leadCwd.resolve("handover.md").normalize().toString();
|
String expectedHandoverPath = leadCwd.resolve("handover.md").normalize().toString();
|
||||||
assertEquals(expectedHandoverPath, pending.handoverPath());
|
assertEquals(expectedHandoverPath, pending.handoverPath());
|
||||||
@@ -223,40 +268,109 @@ class FleetdLeadRolloverAssemblyTest {
|
|||||||
// constructor), so this polls the real FleetMcp.leadRollover() instance's status(token)
|
// constructor), so this polls the real FleetMcp.leadRollover() instance's status(token)
|
||||||
// until the real continuation finishes.
|
// until the real continuation finishes.
|
||||||
LeadRollover.RollStatus status = pollUntilTerminal(rollover, pending.token());
|
LeadRollover.RollStatus status = pollUntilTerminal(rollover, pending.token());
|
||||||
assertEquals(LeadRollover.RollState.ROLLED, status.state(),
|
|
||||||
"the full happy path must complete: FakeHerdr's default agent status is 'idle', so "
|
|
||||||
+ "the turn-boundary wait settles immediately and the post-/clear wait "
|
|
||||||
+ "releases via its pickup-grace path — detail: " + status.detail());
|
|
||||||
|
|
||||||
// Prove the real herdr router actually sent BOTH messages, in order, to the real LEAD
|
// FakeHerdr's pane.get is a fixed canned response that never reports a pane as gone, so the
|
||||||
// pane — this is the one thing a source-text pin on the call site could never show.
|
// real router's death poll runs out its whole budget and the roll stops here — proving the
|
||||||
|
// real teardown call landed on the real LEAD pane without ever reaching a relaunch or a send.
|
||||||
|
assertEquals(LeadRollover.RollState.OLD_PANE_NEVER_DIED, status.state(),
|
||||||
|
"the old pane never reports gone against this fake, so the roll must stop with "
|
||||||
|
+ "OLD_PANE_NEVER_DIED rather than ever relaunching or sending anything — "
|
||||||
|
+ "detail: " + status.detail());
|
||||||
|
|
||||||
|
// Prove the real herdr router actually closed the real LEAD pane — this is the one thing a
|
||||||
|
// source-text pin on the call site could never show.
|
||||||
|
boolean closedOldPane = lead.calls.stream()
|
||||||
|
.anyMatch(c -> c.method().equals("pane.close")
|
||||||
|
&& c.params() instanceof Map<?, ?> m && "w2:p7".equals(m.get("pane_id")));
|
||||||
|
assertTrue(closedOldPane, "endOldSession must close the real old pane (w2:p7) through the "
|
||||||
|
+ "real LEAD herdr client, got calls: " + lead.calls);
|
||||||
|
|
||||||
|
// No agent.prompt is ever sent on this path: the roll stops at the pane-death wait, strictly
|
||||||
|
// before the relaunch and the final send step.
|
||||||
List<FakeHerdr.Call> prompts = lead.calls.stream()
|
List<FakeHerdr.Call> prompts = lead.calls.stream()
|
||||||
.filter(c -> c.method().equals("agent.prompt"))
|
.filter(c -> c.method().equals("agent.prompt"))
|
||||||
.toList();
|
.toList();
|
||||||
assertTrue(prompts.size() >= 2, "expected at least a /clear send and a bootstrapText send "
|
assertTrue(prompts.isEmpty(), "a roll that stops at OLD_PANE_NEVER_DIED must never reach the "
|
||||||
+ "on the LEAD daemon, got " + prompts.size() + " agent.prompt calls: " + prompts);
|
+ "send step, got agent.prompt call(s) on the LEAD daemon: " + prompts);
|
||||||
assertEquals("/clear", ((Map<String, Object>) prompts.get(0).params()).get("text"),
|
|
||||||
"the first send must be the literal /clear housekeeping command");
|
|
||||||
Object secondText = ((Map<String, Object>) prompts.get(1).params()).get("text");
|
|
||||||
assertTrue(secondText instanceof String && ((String) secondText).contains(expectedHandoverPath),
|
|
||||||
"the second send must be the default bootstrapText naming the resolved handover "
|
|
||||||
+ "path, got: " + secondText);
|
|
||||||
|
|
||||||
// fleetd #612 B3 correction: prove the roll never touches the MEMBER daemon. A mutation
|
// fleetd #612 B3 correction: prove the roll never touches the MEMBER daemon. A mutation
|
||||||
// swapping router.leadAgents() for router.memberAgents() at the real call site would move
|
// swapping router.leadAgents() for router.memberAgents() at the real call site would move
|
||||||
// both sends above onto `member` instead, which this assertion catches — the thing the
|
// the pane.close call above onto `member` instead, which this assertion catches — the thing
|
||||||
// single-fake version of this test could never see, because both wrapped the same client.
|
// the single-fake version of this test could never see, because both wrapped the same client.
|
||||||
|
assertTrue(member.calls.isEmpty(), "the roll must be wired to the LEAD daemon only — got "
|
||||||
|
+ member.calls.size() + " call(s) recorded on the MEMBER daemon since the roll began: "
|
||||||
|
+ member.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the relaunch site {@link #assembledLeadRolloverEndsTheOldPaneThroughTheRealHerdrRouter}
|
||||||
|
* never reaches: with the old pane confirmed gone, the roll relaunches a fresh lead, and
|
||||||
|
* {@code bootstrapText} must reach it even though recognition times out (FakeHerdr's
|
||||||
|
* {@code tab.list} is a fixed canned response that never reflects the relaunch's own
|
||||||
|
* {@code tab.rename}, so the fresh terminal is never recognised as a live lead). Same
|
||||||
|
* dual-socket shape as the sibling test: two distinct {@link FakeHerdr} instances, so a
|
||||||
|
* {@code bootstrapText} send wired to the wrong daemon is visible.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@DisplayName("[BEHAVIOURAL] bootstrapText reaches the fresh LEAD terminal even when recognition "
|
||||||
|
+ "times out, and the MEMBER daemon never sees it")
|
||||||
|
void bootstrapTextReachesTheFreshLeadTerminalEvenWhenRecognitionTimesOut(@TempDir Path dir) throws Exception {
|
||||||
|
Path leadCwd = dir.resolve("lead-workspace");
|
||||||
|
Files.createDirectories(leadCwd);
|
||||||
|
FleetConfig cfg = writeConfigWithRelaunchableLead(dir, leadCwd);
|
||||||
|
ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg);
|
||||||
|
SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet());
|
||||||
|
RecordingResourcePorts ports = new RecordingResourcePorts();
|
||||||
|
FakeHerdr lead = new FakeHerdr();
|
||||||
|
lead.withTab("w2", "w2:t7", "lead: opus");
|
||||||
|
// Lets the old pane (w2:p7) report gone once pane.close actually reaches it, so the roll
|
||||||
|
// proceeds to relaunch instead of stopping at OLD_PANE_NEVER_DIED.
|
||||||
|
lead.paneGoneAfterClose("w2:p7");
|
||||||
|
FakeHerdr member = new FakeHerdr();
|
||||||
|
ports.herdrsBySocket.put(LEAD_SOCKET, lead);
|
||||||
|
ports.herdrsBySocket.put(MEMBER_SOCKET, member);
|
||||||
|
|
||||||
|
FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports);
|
||||||
|
|
||||||
|
LeadRollover rollover = runtime.mcp().leadRollover();
|
||||||
|
assertNotNull(rollover, "leadRollover: is present in this test's config, so a real "
|
||||||
|
+ "LeadRollover must have been built");
|
||||||
|
|
||||||
|
LeadRollover.PendingRollover pending = rollover.open("term_a", "bootstrapText relaunch test");
|
||||||
|
Thread.sleep(50);
|
||||||
|
Files.writeString(Path.of(pending.handoverPath()), "handover content for bootstrapText test");
|
||||||
|
|
||||||
|
LeadRollover.RollDecision decision = rollover.confirm("term_a", pending.token(), true);
|
||||||
|
assertTrue(decision.accepted(), "confirm() must approve — got: " + decision);
|
||||||
|
|
||||||
|
LeadRollover.RollStatus status = pollUntilTerminal(rollover, pending.token());
|
||||||
|
assertEquals(LeadRollover.RollState.RELAUNCH_NOT_RECOGNISED, status.state(),
|
||||||
|
"the fresh terminal is never recognised against this fake's static tab.list, so the "
|
||||||
|
+ "roll must reach RELAUNCH_NOT_RECOGNISED — not an earlier failure state and "
|
||||||
|
+ "not ROLLED — detail: " + status.detail());
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<FakeHerdr.Call> leadPrompts = lead.calls.stream()
|
||||||
|
.filter(c -> c.method().equals("agent.prompt"))
|
||||||
|
.toList();
|
||||||
|
assertEquals(1, leadPrompts.size(), "exactly one bootstrapText send is expected, on the LEAD "
|
||||||
|
+ "daemon, once recognition gives up — got: " + leadPrompts);
|
||||||
|
Object text = ((Map<String, Object>) leadPrompts.get(0).params()).get("text");
|
||||||
|
assertTrue(text instanceof String && ((String) text).contains("handover.md"),
|
||||||
|
"the send must be bootstrapText naming the resolved handover path, got: " + text);
|
||||||
|
|
||||||
|
// Scoped to agent.prompt specifically, not every MEMBER call: the orphan-worker reap also
|
||||||
|
// talks to the MEMBER daemon once, unconditionally, at daemon boot — unrelated to this roll.
|
||||||
List<FakeHerdr.Call> memberPrompts = member.calls.stream()
|
List<FakeHerdr.Call> memberPrompts = member.calls.stream()
|
||||||
.filter(c -> c.method().equals("agent.prompt"))
|
.filter(c -> c.method().equals("agent.prompt"))
|
||||||
.toList();
|
.toList();
|
||||||
assertTrue(memberPrompts.isEmpty(), "the roll must be wired to the LEAD daemon only — got "
|
assertTrue(memberPrompts.isEmpty(), "bootstrapText must never be sent to the MEMBER daemon, "
|
||||||
+ memberPrompts.size() + " agent.prompt call(s) on the MEMBER daemon instead: "
|
+ "got: " + memberPrompts);
|
||||||
+ memberPrompts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LeadRollover.RollStatus pollUntilTerminal(LeadRollover rollover, String token)
|
private static LeadRollover.RollStatus pollUntilTerminal(LeadRollover rollover, String token)
|
||||||
throws InterruptedException {
|
throws InterruptedException {
|
||||||
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10);
|
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
|
||||||
while (System.nanoTime() < deadline) {
|
while (System.nanoTime() < deadline) {
|
||||||
LeadRollover.RollStatus status = rollover.status(token);
|
LeadRollover.RollStatus status = rollover.status(token);
|
||||||
if (status.state() != LeadRollover.RollState.PENDING
|
if (status.state() != LeadRollover.RollState.PENDING
|
||||||
@@ -283,8 +397,10 @@ class FleetdLeadRolloverAssemblyTest {
|
|||||||
""");
|
""");
|
||||||
ConfigRef config = new ConfigRef(yaml, FleetConfig.load(yaml));
|
ConfigRef config = new ConfigRef(yaml, FleetConfig.load(yaml));
|
||||||
AgentControl agents = new AgentControl(new FakeHerdr());
|
AgentControl agents = new AgentControl(new FakeHerdr());
|
||||||
|
WorkspaceControl spaces = new WorkspaceControl(new FakeHerdr());
|
||||||
|
LeadLauncher launcher = new LeadLauncher(agents, spaces, config.get());
|
||||||
|
|
||||||
LeadRollover rollover = Fleetd.leadRollover(config.get(), agents, config, Map::of);
|
LeadRollover rollover = Fleetd.leadRollover(config.get(), agents, spaces, launcher, config, Map::of);
|
||||||
|
|
||||||
assertNull(rollover, "leadRollover: is absent from this config, so the factory's opt-in "
|
assertNull(rollover, "leadRollover: is absent from this config, so the factory's opt-in "
|
||||||
+ "gate (`if (cfg.leadRollover() == null) return null;`) must fire and no "
|
+ "gate (`if (cfg.leadRollover() == null) return null;`) must fire and no "
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import dev.ltms.fleet.config.ConfigRef;
|
|||||||
import dev.ltms.fleet.config.FleetConfig;
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.FakeHerdr;
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
|
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||||
|
import dev.ltms.fleet.lead.LeadLauncher;
|
||||||
import dev.ltms.fleet.lead.LeadRollover;
|
import dev.ltms.fleet.lead.LeadRollover;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -54,6 +56,11 @@ class FleetdLeadRolloverWorkspaceLookupTest {
|
|||||||
return new AgentControl(new FakeHerdr());
|
return new AgentControl(new FakeHerdr());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** None of this class's tests reach the deferred continuation, so a plain fake is enough. */
|
||||||
|
private static LeadLauncher fakeLauncher(FleetConfig cfg) {
|
||||||
|
return new LeadLauncher(fakeAgents(), new WorkspaceControl(new FakeHerdr()), cfg);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("[BEHAVIOURAL] Fleetd.leadRollover(...) resolves a relative handoverPath against "
|
@DisplayName("[BEHAVIOURAL] Fleetd.leadRollover(...) resolves a relative handoverPath against "
|
||||||
+ "the CALLING lead's configured cwd, not the daemon's own working directory")
|
+ "the CALLING lead's configured cwd, not the daemon's own working directory")
|
||||||
@@ -74,7 +81,8 @@ class FleetdLeadRolloverWorkspaceLookupTest {
|
|||||||
""".formatted(leadCwd.toString()));
|
""".formatted(leadCwd.toString()));
|
||||||
ConfigRef config = new ConfigRef(yaml, FleetConfig.load(yaml));
|
ConfigRef config = new ConfigRef(yaml, FleetConfig.load(yaml));
|
||||||
|
|
||||||
LeadRollover rollover = Fleetd.leadRollover(config.get(), fakeAgents(), config,
|
LeadRollover rollover = Fleetd.leadRollover(config.get(), fakeAgents(),
|
||||||
|
new WorkspaceControl(new FakeHerdr()), fakeLauncher(config.get()), config,
|
||||||
() -> Map.of("term_opus", "opus"));
|
() -> Map.of("term_opus", "opus"));
|
||||||
assertNotNull(rollover, "leadRollover: is present in the loaded config, so the factory "
|
assertNotNull(rollover, "leadRollover: is present in the loaded config, so the factory "
|
||||||
+ "must construct an object");
|
+ "must construct an object");
|
||||||
@@ -105,7 +113,8 @@ class FleetdLeadRolloverWorkspaceLookupTest {
|
|||||||
|
|
||||||
// No lead has been discovered yet — exactly the real shape of a lead the live tab scan
|
// No lead has been discovered yet — exactly the real shape of a lead the live tab scan
|
||||||
// has not yet scanned, or one with no fleet.leaders entry at all.
|
// has not yet scanned, or one with no fleet.leaders entry at all.
|
||||||
LeadRollover rollover = Fleetd.leadRollover(config.get(), fakeAgents(), config, Map::of);
|
LeadRollover rollover = Fleetd.leadRollover(config.get(), fakeAgents(),
|
||||||
|
new WorkspaceControl(new FakeHerdr()), fakeLauncher(config.get()), config, Map::of);
|
||||||
assertNotNull(rollover);
|
assertNotNull(rollover);
|
||||||
|
|
||||||
LeadRollover.PendingRollover pending = rollover.open("term_unknown", "test");
|
LeadRollover.PendingRollover pending = rollover.open("term_unknown", "test");
|
||||||
@@ -144,7 +153,8 @@ class FleetdLeadRolloverWorkspaceLookupTest {
|
|||||||
// below — exactly the natural mistake to make, since leads are discovered by a live tab
|
// below — exactly the natural mistake to make, since leads are discovered by a live tab
|
||||||
// scan that runs AFTER this factory is constructed at startup.
|
// scan that runs AFTER this factory is constructed at startup.
|
||||||
Map<String, String> liveLeadTerminals = new HashMap<>();
|
Map<String, String> liveLeadTerminals = new HashMap<>();
|
||||||
LeadRollover rollover = Fleetd.leadRollover(config.get(), fakeAgents(), config,
|
LeadRollover rollover = Fleetd.leadRollover(config.get(), fakeAgents(),
|
||||||
|
new WorkspaceControl(new FakeHerdr()), fakeLauncher(config.get()), config,
|
||||||
() -> liveLeadTerminals);
|
() -> liveLeadTerminals);
|
||||||
assertNotNull(rollover);
|
assertNotNull(rollover);
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ class FleetdLeadSeatAssemblyTest {
|
|||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "lead: opus"
|
||||||
profile: sonnet
|
profile: sonnet
|
||||||
|
workspace: "ltms"
|
||||||
profiles:
|
profiles:
|
||||||
sonnet:
|
sonnet:
|
||||||
subscription: true
|
subscription: true
|
||||||
|
|||||||
@@ -1,96 +1,174 @@
|
|||||||
package dev.ltms.fleet;
|
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;
|
||||||
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.ClassFileImporter;
|
||||||
import com.tngtech.archunit.core.importer.ImportOption;
|
import com.tngtech.archunit.core.importer.ImportOption;
|
||||||
import com.tngtech.archunit.library.dependencies.SliceRule;
|
import com.tngtech.archunit.library.dependencies.SliceRule;
|
||||||
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
|
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
|
||||||
import org.junit.jupiter.api.Test;
|
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
|
* Enforces package boundaries between the top-level {@code dev.ltms.fleet.*} packages.
|
||||||
* Maven module split.
|
|
||||||
*
|
*
|
||||||
* <p>This test fails the build the moment a NEW cycle appears between the top-level
|
* <p>{@link #BASELINE_EDGES} names the exact {@code origin class -> target class}
|
||||||
* {@code dev.ltms.fleet.*} packages. Today's cycles are recorded below as explicit,
|
* dependencies allowed to cross a top-level package boundary. Any dependency between two
|
||||||
* narrow exceptions: each one ignores dependencies between exactly the two named
|
* top-level packages that is not in that set fails this test, including a brand new
|
||||||
* packages, in both directions, and nothing else. A cycle through any other pair of
|
* dependency between a pair of packages that already has other baselined edges. A baseline
|
||||||
* packages -- or a brand new pair -- still fails this test.
|
* 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
|
* <p><b>Main code only.</b> The import excludes test classes
|
||||||
* ({@link ImportOption.Predefined#DO_NOT_INCLUDE_TESTS}). Test code legitimately wires
|
* ({@link ImportOption.Predefined#DO_NOT_INCLUDE_TESTS}). Scanning off the classpath via
|
||||||
* across many packages for setup and mocking; that is not part of the shipped
|
* {@code importPackages(...)} keeps this test correct regardless of the working directory
|
||||||
* architecture this rule protects. Verified: importing test classes too pulls in a much
|
* the build is invoked from.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
class PackageCyclesTest {
|
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
|
@Test
|
||||||
void packagesAreFreeOfCycles() {
|
void packagesAreFreeOfCycles() {
|
||||||
var classes = new ClassFileImporter()
|
JavaClasses classes = new ClassFileImporter()
|
||||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||||
.importPackages("dev.ltms.fleet");
|
.importPackages("dev.ltms.fleet");
|
||||||
|
|
||||||
|
checkBaselineMatchesTodaysEdges(classes);
|
||||||
|
|
||||||
SliceRule rule = SlicesRuleDefinition.slices()
|
SliceRule rule = SlicesRuleDefinition.slices()
|
||||||
.matching("dev.ltms.fleet.(*)..")
|
.matching("dev.ltms.fleet.(*)..")
|
||||||
.should().beFreeOfCycles();
|
.should().beFreeOfCycles();
|
||||||
|
for (String edge : BASELINE_EDGES) {
|
||||||
// fleetd #131 step 1: move ConnectionIdentity so authz stops depending on the
|
String[] originAndTarget = edge.split(" -> ");
|
||||||
// MCP layer. Evidence: auth/CallerResolver.java:3 imports mcp.ConnectionIdentity;
|
rule = rule.ignoreDependency(originAndTarget[0], originAndTarget[1]);
|
||||||
// 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");
|
|
||||||
|
|
||||||
rule.check(classes);
|
rule.check(classes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accepts today's known cycle between two top-level packages, and nothing else.
|
* Fails with the exact offending edge when the live code and {@link #BASELINE_EDGES}
|
||||||
* Ignoring both directions removes exactly this pair from cycle detection; every
|
* disagree: a dependency crossing a baselined package pair that is not in the baseline,
|
||||||
* other dependency -- including any new one added later, between these same two
|
* or a baseline entry whose dependency no longer exists.
|
||||||
* packages or any other pair -- is still checked.
|
|
||||||
*/
|
*/
|
||||||
private static SliceRule ignoreCycle(SliceRule rule, String packageA, String packageB) {
|
private static void checkBaselineMatchesTodaysEdges(JavaClasses classes) {
|
||||||
return rule
|
Set<String> baselinedPackagePairs = new TreeSet<>();
|
||||||
.ignoreDependency(residesIn(packageA), residesIn(packageB))
|
for (String edge : BASELINE_EDGES) {
|
||||||
.ignoreDependency(residesIn(packageB), residesIn(packageA));
|
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) {
|
private static String unorderedPair(String packageA, String packageB) {
|
||||||
return Predicates.resideInAPackage("dev.ltms.fleet." + topLevelPackage + "..");
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class AuthzTest {
|
|||||||
private static final Principal ANON = Principal.anonymous();
|
private static final Principal ANON = Principal.anonymous();
|
||||||
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
|
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
|
||||||
private static final Principal ARCH_OTHER = Principal.architect("reviewer", "term_review", 500);
|
private static final Principal ARCH_OTHER = Principal.architect("reviewer", "term_review", 500);
|
||||||
|
private static final Principal COLLABORATOR = Principal.collaborator("ops", "term_collab", 600);
|
||||||
|
private static final Principal OBSERVER = Principal.observer("term_observer", 700);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anonymousIsAuthorizedForNothing() {
|
void anonymousIsAuthorizedForNothing() {
|
||||||
@@ -29,6 +31,44 @@ class AuthzTest {
|
|||||||
assertTrue(Authz.isUnauthenticated(null));
|
assertTrue(Authz.isUnauthenticated(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code MessageService.answer}'s turn-ownership check treats a caller with no terminal as
|
||||||
|
* matching a turn recorded for the unnamed primary. That rule only stays safe because an
|
||||||
|
* unauthenticated caller — whose terminal is also {@code null} — never reaches {@code answer}
|
||||||
|
* at all: {@link #anonymousIsAuthorizedForNothing} already covers every action including
|
||||||
|
* {@code ANSWER}, but this test names the exact coupling so a future change to either side
|
||||||
|
* cannot drift without turning this test red.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void anAnonymousCallerIsRefusedAnswerSoItCanNeverBeMistakenForTheUnnamedPrimary() {
|
||||||
|
assertFalse(Authz.permits(ANON, ANSWER, null),
|
||||||
|
"an anonymous caller, whose terminal is also null, must never reach answer() — the "
|
||||||
|
+ "turn-ownership check's null-terminal match for the unnamed primary owner "
|
||||||
|
+ "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
|
@Test
|
||||||
void orchestrationBelongsToThePrimaryAlone() {
|
void orchestrationBelongsToThePrimaryAlone() {
|
||||||
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, SEND, DRAIN}) {
|
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, SEND, DRAIN}) {
|
||||||
@@ -38,6 +78,37 @@ class AuthzTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_send} is three call shapes behind one action name until {@code
|
||||||
|
* FleetMcp#sendAction} picks one: a plain local {@link Authz.Action#SEND}, the {@code coordId}
|
||||||
|
* route ({@link Authz.Action#COORD_SEND}), and the {@code turnId} answer form ({@link
|
||||||
|
* Authz.Action#ANSWER}). All three carry the same grant as the undivided action did — a worker
|
||||||
|
* is excluded from every one, exactly as it was excluded from the one combined action before.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theThreeSendShapesCarryTheSameGrantAsTheOldUndividedAction() {
|
||||||
|
for (Authz.Action a : new Authz.Action[]{SEND, COORD_SEND, ANSWER}) {
|
||||||
|
assertTrue(Authz.permits(PRIMARY, a, "term_a"), "the primary may " + a);
|
||||||
|
assertTrue(Authz.permits(ARCH_DESIGN, a, "term_a"), "an architect may " + a);
|
||||||
|
assertFalse(Authz.permits(WORKER_A, a, "term_a"),
|
||||||
|
"a worker performing " + a + " would be escalating into the orchestrator role");
|
||||||
|
assertFalse(Authz.permits(ANON, a, "term_a"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_poll{ticket}} and {@code fleet_status} are {@link Authz.Action#TASK_READ}, split
|
||||||
|
* out of the roster-only {@link Authz.Action#READ} (fleetd #678). The grant is unchanged from
|
||||||
|
* what the undivided {@code READ} action gave every one of these callers.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void taskReadCarriesTheSameGrantReadDidBeforeTheSplit() {
|
||||||
|
assertTrue(Authz.permits(PRIMARY, TASK_READ, null));
|
||||||
|
assertTrue(Authz.permits(WORKER_A, TASK_READ, null));
|
||||||
|
assertTrue(Authz.permits(ARCH_DESIGN, TASK_READ, null));
|
||||||
|
assertFalse(Authz.permits(ANON, TASK_READ, null));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aWorkerMayReplyAndAskOnlyAsItself() {
|
void aWorkerMayReplyAndAskOnlyAsItself() {
|
||||||
assertTrue(Authz.permits(WORKER_A, REPLY, "term_a"));
|
assertTrue(Authz.permits(WORKER_A, REPLY, "term_a"));
|
||||||
@@ -135,4 +206,184 @@ class AuthzTest {
|
|||||||
assertFalse(Authz.isUnauthenticated(WORKER_A));
|
assertFalse(Authz.isUnauthenticated(WORKER_A));
|
||||||
assertFalse(Authz.isUnauthenticated(PRIMARY));
|
assertFalse(Authz.isUnauthenticated(PRIMARY));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the collaborator matrix ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code SEND} for a collaborator is the one grant that is conditional rather than fixed:
|
||||||
|
* flipping only the classifier's answer for the target flips only this outcome.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMaySendOnlyWhenTheClassifierAcceptsTheTarget() {
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, SEND, "term_lead", target -> true),
|
||||||
|
"the classifier accepting the target must grant SEND");
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, SEND, "term_lead", target -> false),
|
||||||
|
"the classifier refusing the target must deny SEND");
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, SEND, "term_lead"),
|
||||||
|
"the real production classifier recognises no terminal yet, so SEND is refused today");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control for the test above: every other action's result for a collaborator does not move
|
||||||
|
* when the classifier does. Only {@code SEND} is wired to it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theClassifierMovesOnlySendForACollaborator() {
|
||||||
|
for (Authz.Action a : Authz.Action.values()) {
|
||||||
|
if (a == SEND) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
Authz.permits(COLLABORATOR, a, "term_lead"),
|
||||||
|
Authz.permits(COLLABORATOR, a, "term_lead", target -> true),
|
||||||
|
a + " must not depend on the classifier at all");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMayReadAndScrapeMetrics() {
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, READ, null));
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, METRICS, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMayReplyAndAskOnlyAsItsOwnPane() {
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, REPLY, "term_collab"),
|
||||||
|
"its own pane is its own");
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, ASK, "term_collab"));
|
||||||
|
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, REPLY, "term_design"),
|
||||||
|
"a collaborator must not reply on another pane");
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, REPLY, null),
|
||||||
|
"an absent target must not pass the own-session rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every action denied to a collaborator, asserted denied even when the classifier would
|
||||||
|
* accept any target — proving none of these is actually gated on the classifier at all.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorIsDeniedLifecycleCoordinationAndTicketPolling() {
|
||||||
|
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, DRAIN, HANDOVER, ANSWER, COORD_SEND,
|
||||||
|
COORD_READ, TASK_READ}) {
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, a, "term_lead", target -> true),
|
||||||
|
"a collaborator must not " + a + " even when the classifier accepts every target");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCollaboratorIsNotCountedAsPrimaryWorkerOrArchitect() {
|
||||||
|
assertFalse(COLLABORATOR.isPrimary());
|
||||||
|
assertFalse(COLLABORATOR.isWorker());
|
||||||
|
assertFalse(COLLABORATOR.isArchitect());
|
||||||
|
assertTrue(COLLABORATOR.isCollaborator());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaborator is never spawned, so it must not be enrolled in the presence map as an
|
||||||
|
* available member. Control: both a worker and an architect — which ARE spawned — still are.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void isSpawnedMemberIsFalseForACollaboratorButTrueForAWorkerAndAnArchitect() {
|
||||||
|
assertFalse(COLLABORATOR.isSpawnedMember());
|
||||||
|
assertTrue(WORKER_A.isSpawnedMember());
|
||||||
|
assertTrue(ARCH_DESIGN.isSpawnedMember());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the observer matrix ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anObserverMayReadAndScrapeMetrics() {
|
||||||
|
assertTrue(Authz.permits(OBSERVER, READ, null));
|
||||||
|
assertTrue(Authz.permits(OBSERVER, METRICS, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anObserverMayReplyAndAskOnlyAsItsOwnPane() {
|
||||||
|
assertTrue(Authz.permits(OBSERVER, REPLY, "term_observer"), "its own pane is its own");
|
||||||
|
assertTrue(Authz.permits(OBSERVER, ASK, "term_observer"));
|
||||||
|
|
||||||
|
assertFalse(Authz.permits(OBSERVER, REPLY, "term_design"),
|
||||||
|
"an observer must not reply on another pane");
|
||||||
|
assertFalse(Authz.permits(OBSERVER, REPLY, null),
|
||||||
|
"an absent target must not pass the own-session rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 anObserverIsDeniedEverythingBeyondReadMetricsReplyAskInboxAndSend() {
|
||||||
|
for (Authz.Action a : Authz.Action.values()) {
|
||||||
|
if (a == READ || a == METRICS || a == REPLY || a == ASK || a == INBOX || a == SEND) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertFalse(Authz.permits(OBSERVER, a, "term_observer", target -> true),
|
||||||
|
"an observer must not " + a + " even when the classifier accepts every target");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anObserverIsNotCountedAsAnyOtherRole() {
|
||||||
|
assertFalse(OBSERVER.isPrimary());
|
||||||
|
assertFalse(OBSERVER.isWorker());
|
||||||
|
assertFalse(OBSERVER.isArchitect());
|
||||||
|
assertFalse(OBSERVER.isCollaborator());
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
package dev.ltms.fleet.auth;
|
package dev.ltms.fleet.auth;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||||
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.FakeHerdr;
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
import dev.ltms.fleet.herdr.PaneLocator;
|
import dev.ltms.fleet.herdr.PaneLocator;
|
||||||
|
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||||
import dev.ltms.fleet.mcp.ConnectionIdentity;
|
import dev.ltms.fleet.mcp.ConnectionIdentity;
|
||||||
import dev.ltms.fleet.config.FleetConfig;
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
|
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
||||||
import dev.ltms.fleet.peer.MemberRole;
|
import dev.ltms.fleet.peer.MemberRole;
|
||||||
|
import dev.ltms.fleet.session.MemberSession;
|
||||||
|
import dev.ltms.fleet.session.SessionManager;
|
||||||
|
import dev.ltms.fleet.session.WorktreeRequest;
|
||||||
|
import dev.ltms.fleet.session.Worktrees;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@@ -45,17 +57,23 @@ class CallerResolverTest {
|
|||||||
return members;
|
return members;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With no roster wired up at all (the simple constructor), a loopback pane that owns a herdr
|
||||||
|
* pane but is not recognised as a live spawned member lands on the {@link Role#OBSERVER} floor
|
||||||
|
* — unforgeable and never token-gated, exactly like a worker's own identity, because it comes
|
||||||
|
* from the same connection-derived pane mapping.
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
void aLoopbackWorkerPaneResolvesToWorkerRegardlessOfAuthMode() {
|
void aLoopbackPaneWithNoLiveRosterResolvesToObserverRegardlessOfAuthMode() {
|
||||||
Principal underTrust = new CallerResolver(workerIdentity()).resolve("127.0.0.1", 42, null);
|
Principal underTrust = new CallerResolver(workerIdentity()).resolve("127.0.0.1", 42, null);
|
||||||
Principal underToken = new CallerResolver(workerIdentity(), true, "s3cret")
|
Principal underToken = new CallerResolver(workerIdentity(), true, "s3cret")
|
||||||
.resolve("127.0.0.1", 42, null);
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
assertEquals(Role.WORKER, underTrust.role());
|
assertEquals(Role.OBSERVER, underTrust.role());
|
||||||
assertEquals("term_a", underTrust.terminal());
|
assertEquals("term_a", underTrust.terminal());
|
||||||
assertEquals(Role.WORKER, underToken.role(),
|
assertEquals(Role.OBSERVER, underToken.role(),
|
||||||
"worker identity is unforgeable and must never be token-gated — otherwise enabling "
|
"the floor is unforgeable and must never be token-gated — otherwise enabling auth "
|
||||||
+ "auth would lock the whole fleet out of fleet_reply");
|
+ "would lock every unconfigured pane out of even READ");
|
||||||
assertEquals("term_a", underToken.terminal());
|
assertEquals("term_a", underToken.terminal());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,20 +97,20 @@ class CallerResolverTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void otherPanesRemainWorkersWhenAPinIsSet() {
|
void otherPanesRemainAtTheFloorWhenAPinIsSet() {
|
||||||
Principal p = CallerResolver.pinnedTo(workerIdentity(), false, null, "term_someone_else")
|
Principal p = CallerResolver.pinnedTo(workerIdentity(), false, null, "term_someone_else")
|
||||||
.resolve("127.0.0.1", 42, null);
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
assertEquals(Role.WORKER, p.role());
|
assertEquals(Role.OBSERVER, p.role());
|
||||||
assertEquals("term_a", p.terminal());
|
assertEquals("term_a", p.terminal());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The pin is optional config, so an absent or whitespace one must change nothing at all. */
|
/** The pin is optional config, so an absent or whitespace one must change nothing at all. */
|
||||||
@Test
|
@Test
|
||||||
void aBlankPinLeavesWorkerResolutionUntouched() {
|
void aBlankPinLeavesFloorResolutionUntouched() {
|
||||||
assertEquals(Role.WORKER,
|
assertEquals(Role.OBSERVER,
|
||||||
CallerResolver.pinnedTo(workerIdentity(), false, null, " ").resolve("127.0.0.1", 42, null).role());
|
CallerResolver.pinnedTo(workerIdentity(), false, null, " ").resolve("127.0.0.1", 42, null).role());
|
||||||
assertEquals(Role.WORKER,
|
assertEquals(Role.OBSERVER,
|
||||||
CallerResolver.pinnedTo(workerIdentity(), false, null, null).resolve("127.0.0.1", 42, null).role());
|
CallerResolver.pinnedTo(workerIdentity(), false, null, null).resolve("127.0.0.1", 42, null).role());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,13 +231,13 @@ class CallerResolverTest {
|
|||||||
* mid-scan teardown into a refusal — the real match is still found and resolves as a worker.
|
* mid-scan teardown into a refusal — the real match is still found and resolves as a worker.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void aHerdrErrorOnANonOwningPaneStillResolvesTheRealWorker() {
|
void aHerdrErrorOnANonOwningPaneStillResolvesTheRealPane() {
|
||||||
FakeHerdr vanishedElsewhere = new FakeHerdr().processInfoFailsForPane("w2:p9", "pane_not_found");
|
FakeHerdr vanishedElsewhere = new FakeHerdr().processInfoFailsForPane("w2:p9", "pane_not_found");
|
||||||
ConnectionIdentity id = new ConnectionIdentity(new PaneLocator(vanishedElsewhere), _ -> FakeHerdr.WORKER_PID);
|
ConnectionIdentity id = new ConnectionIdentity(new PaneLocator(vanishedElsewhere), _ -> FakeHerdr.WORKER_PID);
|
||||||
|
|
||||||
Principal p = new CallerResolver(id).resolve("127.0.0.1", 55555, null);
|
Principal p = new CallerResolver(id).resolve("127.0.0.1", 55555, null);
|
||||||
|
|
||||||
assertEquals(Role.WORKER, p.role());
|
assertEquals(Role.OBSERVER, p.role());
|
||||||
assertEquals("term_a", p.terminal());
|
assertEquals("term_a", p.terminal());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,12 +281,12 @@ class CallerResolverTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aPaneAbsentFromTheRegistryIsStillAWorker() {
|
void aPaneAbsentFromTheRegistryFallsToTheObserverFloor() {
|
||||||
Principal p = new CallerResolver(workerIdentity(), false, null,
|
Principal p = new CallerResolver(workerIdentity(), false, null,
|
||||||
Map.of("term_elsewhere", "gpt-sol-5.6"))
|
Map.of("term_elsewhere", "gpt-sol-5.6"))
|
||||||
.resolve("127.0.0.1", 42, null);
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
assertEquals(Role.WORKER, p.role());
|
assertEquals(Role.OBSERVER, p.role());
|
||||||
assertEquals("term_a", p.terminal());
|
assertEquals("term_a", p.terminal());
|
||||||
assertNull(p.name());
|
assertNull(p.name());
|
||||||
}
|
}
|
||||||
@@ -294,16 +312,16 @@ class CallerResolverTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anEmptyRegistryLeavesEveryPaneAWorker() {
|
void anEmptyRegistryLeavesEveryPaneAtTheObserverFloor() {
|
||||||
Map<String, String> noLeads = null;
|
Map<String, String> noLeads = null;
|
||||||
assertEquals(Role.WORKER,
|
assertEquals(Role.OBSERVER,
|
||||||
new CallerResolver(workerIdentity(), false, null, Map.of())
|
new CallerResolver(workerIdentity(), false, null, Map.of())
|
||||||
.resolve("127.0.0.1", 42, null).role());
|
.resolve("127.0.0.1", 42, null).role());
|
||||||
assertEquals(Role.WORKER,
|
assertEquals(Role.OBSERVER,
|
||||||
new CallerResolver(workerIdentity(), false, null, noLeads)
|
new CallerResolver(workerIdentity(), false, null, noLeads)
|
||||||
.resolve("127.0.0.1", 42, null).role());
|
.resolve("127.0.0.1", 42, null).role());
|
||||||
// CB-531: and the same for the live-registry form, whose supplier may also be absent.
|
// And the same for the live-registry form, whose supplier may also be absent.
|
||||||
assertEquals(Role.WORKER,
|
assertEquals(Role.OBSERVER,
|
||||||
CallerResolver.withLeads(workerIdentity(), false, null, null)
|
CallerResolver.withLeads(workerIdentity(), false, null, null)
|
||||||
.resolve("127.0.0.1", 42, null).role());
|
.resolve("127.0.0.1", 42, null).role());
|
||||||
}
|
}
|
||||||
@@ -376,7 +394,7 @@ class CallerResolverTest {
|
|||||||
Map<String, String> live = new java.util.HashMap<>();
|
Map<String, String> live = new java.util.HashMap<>();
|
||||||
CallerResolver r = CallerResolver.withLeads(workerIdentity(), false, null, () -> live);
|
CallerResolver r = CallerResolver.withLeads(workerIdentity(), false, null, () -> live);
|
||||||
|
|
||||||
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
|
assertEquals(Role.OBSERVER, r.resolve("127.0.0.1", 42, null).role());
|
||||||
|
|
||||||
live.put("term_a", "gpt-sol-5.6"); // the scanner sees a newly-labelled tab
|
live.put("term_a", "gpt-sol-5.6"); // the scanner sees a newly-labelled tab
|
||||||
|
|
||||||
@@ -393,7 +411,7 @@ class CallerResolverTest {
|
|||||||
|
|
||||||
mutable.put("term_a", "sneaky");
|
mutable.put("term_a", "sneaky");
|
||||||
|
|
||||||
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
|
assertEquals(Role.OBSERVER, r.resolve("127.0.0.1", 42, null).role());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CB-548: architect slots ─────────────────────────────────────────────────────────────────
|
// ── CB-548: architect slots ─────────────────────────────────────────────────────────────────
|
||||||
@@ -423,13 +441,13 @@ class CallerResolverTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anUnboundPaneStillResolvesAsAWorker() {
|
void anUnboundPaneResolvesToTheObserverFloor() {
|
||||||
MemberRegistry members = new MemberRegistry(new FleetConfig.Fleet(Map.of(),
|
MemberRegistry members = new MemberRegistry(new FleetConfig.Fleet(Map.of(),
|
||||||
Map.of("lead-designer", new FleetConfig.Slot("sonnet")), Map.of(), Map.of(), null));
|
Map.of("lead-designer", new FleetConfig.Slot("sonnet")), Map.of(), Map.of(), null));
|
||||||
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||||
Map::of, members).resolve("127.0.0.1", 42, null);
|
Map::of, members).resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
assertEquals(Role.WORKER, p.role());
|
assertEquals(Role.OBSERVER, p.role());
|
||||||
assertNull(p.name());
|
assertNull(p.name());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,12 +473,11 @@ class CallerResolverTest {
|
|||||||
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||||
Map::of, members);
|
Map::of, members);
|
||||||
|
|
||||||
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
|
assertEquals(Role.OBSERVER, r.resolve("127.0.0.1", 42, null).role());
|
||||||
|
|
||||||
assertTrue(members.bind("architect:lead-designer", "term_a")); // the later lifecycle binds the slot
|
assertTrue(members.bind("architect:lead-designer", "term_a")); // the later lifecycle binds the slot
|
||||||
|
|
||||||
assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role());
|
assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role());
|
||||||
assertEquals("architect:lead-designer", r.members().get("term_a"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -483,12 +500,13 @@ class CallerResolverTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aBoundNonArchitectSlotStillResolvesAsAWorker() {
|
void aBoundNonArchitectSlotResolvesToTheObserverFloorNotArchitect() {
|
||||||
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||||
Map::of, boundMembers("dev:builder", MemberRole.DEV))
|
Map::of, boundMembers("dev:builder", MemberRole.DEV))
|
||||||
.resolve("127.0.0.1", 42, null);
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
assertEquals(Role.WORKER, p.role(), "a dev binding must never grant architect rights");
|
assertEquals(Role.OBSERVER, p.role(), "a dev binding must never grant architect rights, "
|
||||||
|
+ "and this construction path wires no roster to recognise it as the live dev it is");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -499,17 +517,17 @@ class CallerResolverTest {
|
|||||||
assertThrows(IllegalArgumentException.class, () -> new CallerResolver(id, true, " "));
|
assertThrows(IllegalArgumentException.class, () -> new CallerResolver(id, true, " "));
|
||||||
}
|
}
|
||||||
@Test
|
@Test
|
||||||
void aWorkerOnAnyLoopbackSourceAddressIsStillAWorkerNotThePrimary() {
|
void aPaneOnAnyLoopbackSourceAddressIsStillAtTheFloorNotThePrimary() {
|
||||||
// fleetd #305: the escalation. ConnectionIdentity used to accept only 127.0.0.1, so a
|
// fleetd #305: the escalation this guards against. ConnectionIdentity used to accept only
|
||||||
// worker connecting from 127.0.0.2 resolved to no terminal, and this resolver's own
|
// 127.0.0.1, so a pane connecting from 127.0.0.2 resolved to no terminal, and this
|
||||||
// (wider) loopback check then made it the PRIMARY — granting spawn, stop, send and drain.
|
// resolver's own (wider) loopback check then made it the PRIMARY — granting spawn, stop,
|
||||||
// Measured on the Linux fleet host: binding a source of 127.0.0.2 succeeds there, so the
|
// send and drain. Measured on the Linux fleet host: binding a source of 127.0.0.2 succeeds
|
||||||
// path is real and not theoretical.
|
// there, so the path is real and not theoretical.
|
||||||
CallerResolver r = new CallerResolver(workerIdentity(), false, null);
|
CallerResolver r = new CallerResolver(workerIdentity(), false, null);
|
||||||
for (String src : new String[]{"127.0.0.1", "127.0.0.2", "127.1.2.3", "::ffff:127.0.0.2"}) {
|
for (String src : new String[]{"127.0.0.1", "127.0.0.2", "127.1.2.3", "::ffff:127.0.0.2"}) {
|
||||||
Principal p = r.resolve(src, 55555, null);
|
Principal p = r.resolve(src, 55555, null);
|
||||||
assertEquals(Role.WORKER, p.role(), "a worker must stay a worker from source " + src);
|
assertEquals(Role.OBSERVER, p.role(), "the pane must stay off PRIMARY from source " + src);
|
||||||
assertEquals("term_a", p.terminal(), "worker terminal from source " + src);
|
assertEquals("term_a", p.terminal(), "pane terminal from source " + src);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,4 +541,487 @@ class CallerResolverTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #669 Unit D: a live spawned member outranks every tab map ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Criterion 1: a terminal present in BOTH the spawned-member roster AND the lead tab map
|
||||||
|
* resolves as its member role, not as a lead — the roster is checked first, consulting no tab
|
||||||
|
* map at all when it matches.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aSpawnedMemberWinsOverALeadTabForTheSamePane() {
|
||||||
|
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||||
|
() -> Map.of("term_a", "opus-5.0"), new MemberRegistry(null),
|
||||||
|
t -> "term_a".equals(t) ? MemberRole.DEV : null, Map::of)
|
||||||
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
|
assertEquals(Role.WORKER, p.role(),
|
||||||
|
"a live spawned member's own identity must win over a tab map naming the same pane a lead");
|
||||||
|
assertEquals("term_a", p.terminal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #702: between {@link SessionManager#release} removing the registry entry and the
|
||||||
|
* pane actually stopping, a resolve for that terminal must still see the live member and
|
||||||
|
* never fall through to a tab map — wired through the real {@link SessionManager}, not a
|
||||||
|
* hand-rolled stand-in for its {@code spawnedMemberRole}.
|
||||||
|
*
|
||||||
|
* <p>Reuses the ticket's own test idea: an injected {@code hasUncommitted} resolves the
|
||||||
|
* releasing terminal from inside {@code release}'s window — a real call landing inside the
|
||||||
|
* window, so no sleep and no race.
|
||||||
|
*
|
||||||
|
* <p>The property asserted is "no tab map is consulted", never "the same role is returned".
|
||||||
|
* The mandatory control is the lead tab map: it names this exact terminal, and the same
|
||||||
|
* resolve taken <em>outside</em> the window (before release runs) must still return the lead
|
||||||
|
* role — without that control, the in-window assertion would also pass on an empty map and
|
||||||
|
* prove nothing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aPaneMidTeardownResolvesAsItsOwnRoleConsultingNoTabMap() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().pinNextStarts(1, "term_a", "w2:p7");
|
||||||
|
FleetConfig.Profile cfg = new FleetConfig.Profile(
|
||||||
|
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
|
||||||
|
List.of("ccs", "ltms-local"), "tab", "fleetd-workers",
|
||||||
|
"worker: {profile} #{n}", null, null, null);
|
||||||
|
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||||
|
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||||
|
|
||||||
|
AtomicReference<Principal> duringWindow = new AtomicReference<>();
|
||||||
|
AtomicReference<CallerResolver> resolverRef = new AtomicReference<>();
|
||||||
|
Worktrees worktrees = new Worktrees() {
|
||||||
|
@Override
|
||||||
|
public String add(String repoRoot, String branch, String baseRef) {
|
||||||
|
return "/wt/" + branch.replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void remove(String repoRoot, String worktreePath) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteBranch(String repoRoot, String branch) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasUncommitted(String worktreePath) {
|
||||||
|
// Runs from INSIDE release()'s git-status shell-out: the registry entry is
|
||||||
|
// already gone, but the pane has not stopped yet.
|
||||||
|
duringWindow.set(resolverRef.get().resolve("127.0.0.1", 42, null));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void overlayParity(String repoRoot, String worktreePath, List<String> overlay) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String repoRoot(String cwd) {
|
||||||
|
return "/repo";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<String> snapshot(String worktreePath, String branch, String message) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WipRefStats wipRefs(String repoRoot) {
|
||||||
|
return new WipRefStats(0, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int pruneWipRefs(String repoRoot, long minAgeMillis) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shareWithGroup(String repoRoot, String worktreePath) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SessionManager sessions = new SessionManager(workers, worktrees);
|
||||||
|
|
||||||
|
// The lead tab map names "term_a" before anything is ever spawned onto it — the control
|
||||||
|
// this test needs. Built up front so the SAME resolver answers every resolve() call below.
|
||||||
|
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> FakeHerdr.WORKER_PID);
|
||||||
|
CallerResolver resolver = CallerResolver.withLeadsAndMembers(identity, false, null,
|
||||||
|
() -> Map.of("term_a", "the-lead"), null, sessions::spawnedMemberRole, Map::of);
|
||||||
|
resolverRef.set(resolver);
|
||||||
|
|
||||||
|
Principal before = resolver.resolve("127.0.0.1", 42, null);
|
||||||
|
assertEquals(Role.PRIMARY, before.role(),
|
||||||
|
"control: with no live or releasing member on this terminal, the lead tab map must "
|
||||||
|
+ "win — this is what proves the in-window assertion below is not passing on "
|
||||||
|
+ "an empty map");
|
||||||
|
assertEquals("the-lead", before.name());
|
||||||
|
|
||||||
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
|
||||||
|
new WorktreeRequest("fleetd-702", null));
|
||||||
|
assertEquals("term_a", s.terminalId(), "sanity: the spawn resolved to the pinned pane");
|
||||||
|
|
||||||
|
sessions.release(s.paneId());
|
||||||
|
|
||||||
|
assertNotNull(duringWindow.get(), "the dirty check must have run and captured a resolve");
|
||||||
|
assertEquals(Role.WORKER, duringWindow.get().role(),
|
||||||
|
"inside the window the pane must resolve as its own live-member role, consulting no "
|
||||||
|
+ "tab map — a lead tab naming the same terminal must not win");
|
||||||
|
assertEquals("term_a", duringWindow.get().terminal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link SessionManager#releaseRemoved} unbinds the architect slot before the git-status
|
||||||
|
* shell-out that opens the teardown window, so a resolve landing inside that window must see
|
||||||
|
* the slot already unbound and resolve {@link Role#WORKER} — never {@link Role#ARCHITECT},
|
||||||
|
* and never by checking role equality against the live session, which would hold even if the
|
||||||
|
* unbind ran too late.
|
||||||
|
*
|
||||||
|
* <p>The control is the same resolve taken outside the window, while the slot is still bound,
|
||||||
|
* which must return {@link Role#ARCHITECT} — without it this test would also pass against a
|
||||||
|
* slot that was never bound, and prove nothing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aReleasingArchitectIsDemotedToWorkerInsideTheTeardownWindow() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().pinNextStarts(1, "term_a", "w2:p7");
|
||||||
|
FleetConfig.Profile cfg = new FleetConfig.Profile(
|
||||||
|
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
|
||||||
|
List.of("ccs", "ltms-local"), "tab", "fleetd-workers",
|
||||||
|
"worker: {profile} #{n}", null, null, null);
|
||||||
|
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||||
|
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||||
|
|
||||||
|
AtomicReference<Principal> duringWindow = new AtomicReference<>();
|
||||||
|
AtomicReference<CallerResolver> resolverRef = new AtomicReference<>();
|
||||||
|
Worktrees worktrees = new Worktrees() {
|
||||||
|
@Override
|
||||||
|
public String add(String repoRoot, String branch, String baseRef) {
|
||||||
|
return "/wt/" + branch.replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void remove(String repoRoot, String worktreePath) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteBranch(String repoRoot, String branch) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasUncommitted(String worktreePath) {
|
||||||
|
// Runs from INSIDE release()'s git-status shell-out: the architect slot is already
|
||||||
|
// unbound by this point, but the pane has not stopped yet.
|
||||||
|
duringWindow.set(resolverRef.get().resolve("127.0.0.1", 42, null));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void overlayParity(String repoRoot, String worktreePath, List<String> overlay) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String repoRoot(String cwd) {
|
||||||
|
return "/repo";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<String> snapshot(String worktreePath, String branch, String message) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WipRefStats wipRefs(String repoRoot) {
|
||||||
|
return new WipRefStats(0, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int pruneWipRefs(String repoRoot, long minAgeMillis) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shareWithGroup(String repoRoot, String worktreePath) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SessionManager sessions = new SessionManager(workers, worktrees);
|
||||||
|
|
||||||
|
MemberRegistry members = new MemberRegistry(new FleetConfig.Fleet(Map.of(),
|
||||||
|
Map.of("lead-designer", new FleetConfig.Slot("ltms-local")), Map.of(), Map.of(), null));
|
||||||
|
sessions.setMemberLifecycle(members);
|
||||||
|
|
||||||
|
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> FakeHerdr.WORKER_PID);
|
||||||
|
CallerResolver resolver = CallerResolver.withLeadsAndMembers(identity, false, null,
|
||||||
|
Map::of, members, sessions::spawnedMemberRole, Map::of);
|
||||||
|
resolverRef.set(resolver);
|
||||||
|
|
||||||
|
MemberSession s = sessions.acquire("ltms-local", MemberRole.ARCHITECT, null, "/caller/proj", null,
|
||||||
|
new WorktreeRequest("fleetd-702d", null));
|
||||||
|
assertEquals("term_a", s.terminalId(), "sanity: the spawn resolved to the pinned pane");
|
||||||
|
assertEquals(MemberRole.ARCHITECT, s.role(), "sanity: the slot bind succeeded");
|
||||||
|
|
||||||
|
Principal before = resolver.resolve("127.0.0.1", 42, null);
|
||||||
|
assertEquals(Role.ARCHITECT, before.role(),
|
||||||
|
"control: with the slot still bound, the pane must resolve as an architect — this "
|
||||||
|
+ "is what proves the in-window assertion below is not passing against a "
|
||||||
|
+ "slot that was never bound");
|
||||||
|
assertEquals("lead-designer", before.name());
|
||||||
|
|
||||||
|
sessions.release(s.paneId());
|
||||||
|
|
||||||
|
assertNotNull(duringWindow.get(), "the dirty check must have run and captured a resolve");
|
||||||
|
assertEquals(Role.WORKER, duringWindow.get().role(),
|
||||||
|
"inside the window the architect slot is already unbound, so the result must be "
|
||||||
|
+ "WORKER — asserting role-equality with the live session here would tempt "
|
||||||
|
+ "moving the unbind earlier or later, which would be wrong either way");
|
||||||
|
assertEquals("term_a", duringWindow.get().terminal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spawned architect in the roster resolves ARCHITECT, carrying its bound slot's name. */
|
||||||
|
@Test
|
||||||
|
void aSpawnedArchitectInTheRosterResolvesArchitectWithItsSlotName() {
|
||||||
|
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
|
||||||
|
boundMembers("architect:lead-designer", MemberRole.ARCHITECT),
|
||||||
|
t -> "term_a".equals(t) ? MemberRole.ARCHITECT : null, Map::of)
|
||||||
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
|
assertEquals(Role.ARCHITECT, p.role());
|
||||||
|
assertEquals("lead-designer", p.name());
|
||||||
|
assertEquals("term_a", p.terminal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #424 regression: the roster only answers THAT a pane is a live spawned member; config
|
||||||
|
* still decides WHAT that member's slot grants. A slot revoked after the bind must still demote
|
||||||
|
* the session on its very next request, exactly as it would for a pane with no roster entry at
|
||||||
|
* all — the roster's own ARCHITECT role must never be granted on its word alone.
|
||||||
|
*
|
||||||
|
* <p>{@code bind} refuses an unconfigured slot, so the revoked state can only be reached by
|
||||||
|
* binding while the slot is configured and then swapping the config out from under it, the way
|
||||||
|
* a live reload does.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aRevokedArchitectSlotDemotesALiveSpawnedArchitectToWorker() {
|
||||||
|
FleetConfig.Fleet configured = new FleetConfig.Fleet(Map.of(),
|
||||||
|
Map.of("lead-designer", new FleetConfig.Slot("sonnet")), Map.of(), Map.of(), null);
|
||||||
|
java.util.concurrent.atomic.AtomicReference<FleetConfig.Fleet> live =
|
||||||
|
new java.util.concurrent.atomic.AtomicReference<>(configured);
|
||||||
|
MemberRegistry members = MemberRegistry.live(live::get);
|
||||||
|
assertTrue(members.bind("architect:lead-designer", "term_a"));
|
||||||
|
|
||||||
|
live.set(new FleetConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), null)); // slot revoked
|
||||||
|
|
||||||
|
// Setup controls: the slot is really gone from config, but the occupancy is still there —
|
||||||
|
// otherwise this test would pass for the wrong reason.
|
||||||
|
assertNull(members.roleForSlot("architect:lead-designer"), "setup control: the slot must be gone from config");
|
||||||
|
assertEquals("architect:lead-designer", members.snapshot().get("term_a"),
|
||||||
|
"setup control: the binding itself must still be there");
|
||||||
|
|
||||||
|
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
|
||||||
|
members, t -> "term_a".equals(t) ? MemberRole.ARCHITECT : null, Map::of)
|
||||||
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
|
assertEquals(Role.WORKER, p.role(),
|
||||||
|
"a revoked slot must demote a live spawned architect on its very next request");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Criterion 3: a configured collaborator tab that is not a spawned member resolves COLLABORATOR. */
|
||||||
|
@Test
|
||||||
|
void aConfiguredCollaboratorTabResolvesToCollaboratorCarryingItsName() {
|
||||||
|
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
|
||||||
|
new MemberRegistry(null), t -> null, () -> Map.of("term_a", "ops"))
|
||||||
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
|
assertEquals(Role.COLLABORATOR, p.role());
|
||||||
|
assertEquals("ops", p.name());
|
||||||
|
assertEquals("term_a", p.terminal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regression: an empty collaborator registry leaves every pane at the unconfigured-pane floor. */
|
||||||
|
@Test
|
||||||
|
void anEmptyCollaboratorRegistryLeavesEveryPaneAtTheObserverFloor() {
|
||||||
|
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
|
||||||
|
new MemberRegistry(null), t -> null, Map::of)
|
||||||
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
|
||||||
|
assertEquals(Role.OBSERVER, p.role());
|
||||||
|
assertNull(p.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void describeNamesTheCollaborator() {
|
||||||
|
assertEquals("collaborator:ops", Principal.collaborator("ops", "term_a", 1).describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fleetd #669 Unit D: knownLeadOrCollaborator() reads the same maps resolve() does ───────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void knownLeadOrCollaboratorIsTrueForALeadTerminal() {
|
||||||
|
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||||
|
() -> Map.of("term_lead", "opus-5.0"), new MemberRegistry(null), t -> null, Map::of);
|
||||||
|
|
||||||
|
assertTrue(r.knownLeadOrCollaborator().test("term_lead"));
|
||||||
|
assertFalse(r.knownLeadOrCollaborator().test("term_other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void knownLeadOrCollaboratorIsTrueForACollaboratorTerminal() {
|
||||||
|
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
|
||||||
|
new MemberRegistry(null), t -> null, () -> Map.of("term_collab", "ops"));
|
||||||
|
|
||||||
|
assertTrue(r.knownLeadOrCollaborator().test("term_collab"));
|
||||||
|
assertFalse(r.knownLeadOrCollaborator().test("term_other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fleetd #705: narrowing the unconfigured-pane floor to OBSERVER ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The case this ticket exists for: a pane the resolver cannot place as a live spawned member,
|
||||||
|
* a lead, a bound architect slot, or a configured collaborator must land on the narrow
|
||||||
|
* {@link Role#OBSERVER} floor, never the {@link Role#WORKER} the old fallback granted.
|
||||||
|
*
|
||||||
|
* <p>The second assertion is the control the ticket requires: a terminal the roster DOES
|
||||||
|
* recognise as a live spawned member must still resolve its own role. Without it, this test
|
||||||
|
* would also pass if the fix accidentally turned every caller into an observer.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void anUnconfiguredPaneResolvesObserverButARegisteredMemberStillResolvesItsOwnRole() {
|
||||||
|
Principal unconfigured = new CallerResolver(workerIdentity()).resolve("127.0.0.1", 42, null);
|
||||||
|
assertEquals(Role.OBSERVER, unconfigured.role(),
|
||||||
|
"a pane matching none of the configured or live-roster roles must fall to the "
|
||||||
|
+ "floor, not WORKER");
|
||||||
|
assertEquals("term_a", unconfigured.terminal());
|
||||||
|
|
||||||
|
Principal registered = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
|
||||||
|
Map::of, new MemberRegistry(null),
|
||||||
|
t -> "term_a".equals(t) ? MemberRole.DEV : null, Map::of)
|
||||||
|
.resolve("127.0.0.1", 42, null);
|
||||||
|
assertEquals(Role.WORKER, registered.role(),
|
||||||
|
"control: a live spawned member must keep resolving its own role, never the "
|
||||||
|
+ "unconfigured-pane floor");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void describeNamesTheObserverByItsPane() {
|
||||||
|
assertEquals("observer:term_a", Principal.observer("term_a", 1).describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void knownLeadOrCollaboratorIsFalseForASpawnedMembersTerminal() {
|
||||||
|
// The exact scenario a collaborator's SEND must never reach: a live spawned member's own
|
||||||
|
// terminal, which is neither a configured lead nor a configured collaborator.
|
||||||
|
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
|
||||||
|
new MemberRegistry(null), t -> "term_a".equals(t) ? MemberRole.DEV : null, Map::of);
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,9 +295,10 @@ class MemberRegistryLiveTest {
|
|||||||
assertTrue(out.applied(), "the reload must actually take effect: " + out.summary());
|
assertTrue(out.applied(), "the reload must actually take effect: " + out.summary());
|
||||||
|
|
||||||
Principal after = resolver.resolve("127.0.0.1", 42, null);
|
Principal after = resolver.resolve("127.0.0.1", 42, null);
|
||||||
assertEquals(Role.WORKER, after.role(),
|
assertEquals(Role.OBSERVER, after.role(),
|
||||||
"removing the slot from config must demote the bound session to worker on its "
|
"removing the slot from config must demote the bound session on its NEXT request — "
|
||||||
+ "NEXT request — this is the ticket's whole point");
|
+ "this harness wires no live roster for term_a, so the demotion lands on "
|
||||||
|
+ "the unconfigured-pane floor");
|
||||||
assertEquals("term_a", after.terminal(), "same pane, same terminal — only the role changed");
|
assertEquals("term_a", after.terminal(), "same pane, same terminal — only the role changed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.ltms.fleet.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
class PrincipalTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownerKeyCoversEveryRole() {
|
||||||
|
assertEquals("leader:opus", Principal.leader("opus", "term_lead", 1).ownerKey());
|
||||||
|
assertNull(Principal.primary(2).ownerKey());
|
||||||
|
assertEquals("worker:term_worker", Principal.worker("term_worker", 3).ownerKey());
|
||||||
|
assertEquals("architect:term_arch", Principal.architect("opus", "term_arch", 4).ownerKey());
|
||||||
|
assertEquals("collaborator:ops", Principal.collaborator("ops", "term_collab", 5).ownerKey());
|
||||||
|
assertEquals("observer:term_observer", Principal.observer("term_observer", 6).ownerKey());
|
||||||
|
assertEquals("anonymous", Principal.anonymous().ownerKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rolePrefixesKeepLeadAndArchitectKeysDistinct() {
|
||||||
|
String lead = Principal.leader("opus", "term_lead", 1).ownerKey();
|
||||||
|
String architect = Principal.architect("design", "opus", 2).ownerKey();
|
||||||
|
|
||||||
|
assertEquals("leader:opus", lead);
|
||||||
|
assertEquals("architect:opus", architect);
|
||||||
|
assertNotEquals(lead, architect);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,8 @@ class ConfigRefTest {
|
|||||||
|
|
||||||
assertTrue(out.applied());
|
assertTrue(out.applied());
|
||||||
assertTrue(out.deferred().isEmpty());
|
assertTrue(out.deferred().isEmpty());
|
||||||
|
assertTrue(out.split().stream().noneMatch(s -> s.contains("fleet.collaborators")),
|
||||||
|
out.split().toString());
|
||||||
assertEquals("new charter", ref.get().fleet().charterFor(
|
assertEquals("new charter", ref.get().fleet().charterFor(
|
||||||
dev.ltms.fleet.peer.MemberRole.ARCHITECT));
|
dev.ltms.fleet.peer.MemberRole.ARCHITECT));
|
||||||
}
|
}
|
||||||
@@ -786,14 +788,100 @@ class ConfigRefTest {
|
|||||||
assertTrue(out.split().getFirst().startsWith("fleet:"), out.split().toString());
|
assertTrue(out.split().getFirst().startsWith("fleet:"), out.split().toString());
|
||||||
assertTrue(out.split().getFirst().contains("restart"), out.split().toString());
|
assertTrue(out.split().getFirst().contains("restart"), out.split().toString());
|
||||||
assertTrue(out.split().getFirst().contains("live"), out.split().toString());
|
assertTrue(out.split().getFirst().contains("live"), out.split().toString());
|
||||||
|
assertTrue(out.split().stream().noneMatch(s -> s.contains("fleet.collaborators")),
|
||||||
|
out.split().toString());
|
||||||
assertTrue(out.summary().contains("partially live"), out.summary());
|
assertTrue(out.summary().contains("partially live"), out.summary());
|
||||||
// The snapshot still carries the new value — the LeadTabScanner's identity map and
|
// The snapshot still carries the new value — the LeadTabScanner's identity map and
|
||||||
// LeadLauncher's auto-launch are what wait for a restart; a reload rebuilds neither.
|
// LeadLauncher's auto-launch are what wait for a restart; a reload rebuilds neither.
|
||||||
assertEquals("lead: opus-b", ref.get().fleet().leaders().get("opus").tab());
|
assertEquals("lead: opus-b", ref.get().fleet().leaders().get("opus").tab());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addingAFleetCollaboratorIsReportedAsSplit(@TempDir Path dir) throws Exception {
|
||||||
|
assertCollaboratorChangeIsReported(dir, """
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collaborator: alex"
|
||||||
|
""", "add a collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removingAFleetCollaboratorIsReportedAsSplit(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(f, yaml("""
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collaborator: alex"
|
||||||
|
"""));
|
||||||
|
ConfigRef ref = refFor(f);
|
||||||
|
|
||||||
|
Files.writeString(f, yaml("fleet: {}\n"));
|
||||||
|
assertCollaboratorSplit(ref.reload(), "remove a collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changingAFleetCollaboratorTabIsReportedAsSplit(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(f, yaml("""
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collaborator: alex-a"
|
||||||
|
"""));
|
||||||
|
ConfigRef ref = refFor(f);
|
||||||
|
|
||||||
|
Files.writeString(f, yaml("""
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collaborator: alex-b"
|
||||||
|
"""));
|
||||||
|
assertCollaboratorSplit(ref.reload(), "change a collaborator tab");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unchangedFleetCollaboratorsProduceNoSplitReport(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("fleetd.yaml");
|
||||||
|
String config = yaml("""
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collaborator: alex"
|
||||||
|
""");
|
||||||
|
Files.writeString(f, config);
|
||||||
|
ConfigRef ref = refFor(f);
|
||||||
|
|
||||||
|
Files.writeString(f, config);
|
||||||
|
ConfigRef.Outcome out = ref.reload();
|
||||||
|
|
||||||
|
assertTrue(out.applied());
|
||||||
|
assertTrue(out.split().isEmpty(), out.split().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCollaboratorChangeIsReported(Path dir, String changed, String action)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(f, yaml("fleet: {}\n"));
|
||||||
|
ConfigRef ref = refFor(f);
|
||||||
|
|
||||||
|
Files.writeString(f, yaml(changed));
|
||||||
|
assertCollaboratorSplit(ref.reload(), action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCollaboratorSplit(ConfigRef.Outcome out, String action) {
|
||||||
|
assertTrue(out.applied(), action);
|
||||||
|
assertTrue(out.deferred().isEmpty(), out.deferred().toString());
|
||||||
|
assertEquals(1, out.split().size(), out.split().toString());
|
||||||
|
String report = out.split().getFirst();
|
||||||
|
assertTrue(report.startsWith("fleet: fleet.collaborators"), report);
|
||||||
|
assertTrue(report.contains("read once at startup"), report);
|
||||||
|
assertTrue(report.contains("restart"), report);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fleetd #333: {@code fleet.leaders} is the ONLY frozen part of {@code fleet:}. A reload that
|
* fleetd #333: {@code fleet.leaders} is a frozen part of {@code fleet:}. A reload that
|
||||||
* changes {@code tabLabel} (or charters, or a role pool) without touching {@code fleet.leaders}
|
* changes {@code tabLabel} (or charters, or a role pool) without touching {@code fleet.leaders}
|
||||||
* must stay fully hot with nothing reported — proving {@link ConfigRef#changedSplitKeys}
|
* must stay fully hot with nothing reported — proving {@link ConfigRef#changedSplitKeys}
|
||||||
* compares {@code fleet.leaders} specifically rather than the whole {@code Fleet} record, which
|
* compares {@code fleet.leaders} specifically rather than the whole {@code Fleet} record, which
|
||||||
|
|||||||
@@ -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
|
* A lead's tab label is a fixed constant, not a per-entry field, so an entry with no {@code
|
||||||
* whether the entry is creatable or recognise-only — without it the entry can never be found.
|
* tab:} is the normal case — it is still found by that constant label in its own {@code
|
||||||
|
* workspace}, not refused as useless.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void aLeadWithNoTabRefusesToStart(@TempDir Path dir) throws Exception {
|
void aLeadWithNoTabIsAcceptedAndFoundByTheFixedLabel(@TempDir Path dir) throws Exception {
|
||||||
Path f = dir.resolve("useless-lead.yaml");
|
Path f = dir.resolve("no-tab-lead.yaml");
|
||||||
Files.writeString(f, """
|
Files.writeString(f, """
|
||||||
bind:
|
bind:
|
||||||
port: 8080
|
port: 8080
|
||||||
@@ -553,9 +554,9 @@ class FleetConfigTest {
|
|||||||
""");
|
""");
|
||||||
FleetConfig cfg = FleetConfig.load(f);
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
assertDoesNotThrow(cfg::validateMembers);
|
||||||
assertTrue(e.getMessage().contains("ghost"), "the message must name the useless entry");
|
assertEquals(List.of(FleetConfig.Leader.LEAD_TAB_LABEL),
|
||||||
assertTrue(e.getMessage().contains("tab:"), "the message must say what is missing");
|
cfg.fleet().leaders().get("ghost").acceptedLabels());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -676,12 +677,8 @@ class FleetConfigTest {
|
|||||||
assertEquals(5, hb.quietNudgeCap());
|
assertEquals(5, hb.quietNudgeCap());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The hazard the guard exists for: fleetd writes worker tab labels and reads lead tab labels.
|
|
||||||
* Overlap the two and every worker it spawns is read back as a lead.
|
|
||||||
*/
|
|
||||||
@Test
|
@Test
|
||||||
void aLeadPrefixThatAProfileTabLabelOverrideAlsoMatchesRefusesToStart(@TempDir Path dir)
|
void aProfileTabLabelOverrideMatchingALeadTabRefusesToStart(@TempDir Path dir)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
Path f = dir.resolve("collide.yaml");
|
Path f = dir.resolve("collide.yaml");
|
||||||
Files.writeString(f, """
|
Files.writeString(f, """
|
||||||
@@ -689,32 +686,33 @@ class FleetConfigTest {
|
|||||||
port: 8080
|
port: 8080
|
||||||
profiles:
|
profiles:
|
||||||
gx10:
|
gx10:
|
||||||
tabLabel: "lead: {profile} #{n}"
|
tabLabel: "alpha"
|
||||||
fleet:
|
fleet:
|
||||||
leaders:
|
leaders:
|
||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "alpha"
|
||||||
tabPrefix: "lead:"
|
|
||||||
""");
|
""");
|
||||||
FleetConfig cfg = FleetConfig.load(f);
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
IllegalStateException e =
|
IllegalStateException e =
|
||||||
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||||
|
assertTrue(e.getMessage().contains("alpha"), "the message must name the offending label");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A bad fleet-wide template promotes every member, not one profile — so it is checked too. */
|
|
||||||
@Test
|
@Test
|
||||||
void aFleetTabLabelThatMatchesALeadPrefixRefusesToStart(@TempDir Path dir) throws Exception {
|
void aFleetTabLabelTemplateThatCanRenderAsALeadTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
Path f = dir.resolve("collide-template.yaml");
|
Path f = dir.resolve("collide-template.yaml");
|
||||||
Files.writeString(f, """
|
Files.writeString(f, """
|
||||||
bind:
|
bind:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
pha: {}
|
||||||
fleet:
|
fleet:
|
||||||
tabLabel: "lead: {role} {profile}"
|
tabLabel: "al{profile}"
|
||||||
leaders:
|
leaders:
|
||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "alpha"
|
||||||
""");
|
""");
|
||||||
FleetConfig cfg = FleetConfig.load(f);
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
@@ -723,12 +721,28 @@ class FleetConfigTest {
|
|||||||
assertTrue(e.getMessage().contains("fleet.tabLabel"));
|
assertTrue(e.getMessage().contains("fleet.tabLabel"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The point of making role the label's first field: {@code {role}} comes from a closed enum, so
|
|
||||||
* a generated label cannot begin with {@code "lead:"} however the fleet is configured.
|
|
||||||
*/
|
|
||||||
@Test
|
@Test
|
||||||
void theDefaultTabLabelCannotCollideWithTheDefaultLeadPrefix(@TempDir Path dir) throws Exception {
|
void anExactFleetTabLabelCollisionRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("exact-tab-collision.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
tabLabel: "alpha"
|
||||||
|
leaders:
|
||||||
|
alpha:
|
||||||
|
tab: "alpha"
|
||||||
|
""");
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class,
|
||||||
|
() -> FleetConfig.load(f).validateAll());
|
||||||
|
assertTrue(e.getMessage().contains("fleet.tabLabel"),
|
||||||
|
"the message must name the offending label");
|
||||||
|
assertTrue(e.getMessage().contains("alpha"), "the message must name the colliding lead tab");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFleetTabLabelTemplateThatCannotRenderAsALeadTabIsAllowed(@TempDir Path dir) throws Exception {
|
||||||
Path f = dir.resolve("ok.yaml");
|
Path f = dir.resolve("ok.yaml");
|
||||||
Files.writeString(f, """
|
Files.writeString(f, """
|
||||||
bind:
|
bind:
|
||||||
@@ -737,17 +751,13 @@ class FleetConfigTest {
|
|||||||
gx10:
|
gx10:
|
||||||
baseUrl: http://gx00.gw:8000
|
baseUrl: http://gx00.gw:8000
|
||||||
fleet:
|
fleet:
|
||||||
|
tabLabel: "worker-{profile}"
|
||||||
leaders:
|
leaders:
|
||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "alpha"
|
||||||
""");
|
""");
|
||||||
|
|
||||||
assertDoesNotThrow(() -> FleetConfig.load(f).validateLeadTabPrefixes());
|
assertDoesNotThrow(() -> FleetConfig.load(f).validateAll());
|
||||||
for (MemberRole role : MemberRole.values()) {
|
|
||||||
assertFalse(FleetConfig.Fleet.DEFAULT_TAB_LABEL
|
|
||||||
.replace("{role}", role.wireName()).startsWith("lead:"),
|
|
||||||
"no role renders a label that reads as a lead");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -765,11 +775,132 @@ class FleetConfigTest {
|
|||||||
"a label that collides with a convention nobody reads is not a problem");
|
"a label that collides with a convention nobody reads is not a problem");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 twoLeadersSharingTheSameWorkspaceRefuseToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("shared-workspace.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
workspace: "shared"
|
||||||
|
sonnet:
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 the two tests above: distinct workspaces load cleanly, with no {@code tab:} at all. */
|
||||||
|
@Test
|
||||||
|
void twoLeadersWithDistinctWorkspacesAndNoTabAreAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("distinct-workspaces.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
workspace: "space-opus"
|
||||||
|
sonnet:
|
||||||
|
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 ────────────────────────────────────────────────────
|
// ── validatePanePlacementAgainstLeadTabs ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The hazard this guard closes: a pane-placed member lands inside the focused tab rather than
|
* The hazard this guard closes: a pane-placed member lands inside the focused tab rather than
|
||||||
* its own, so it can land inside a lead's labelled tab and be read back as that lead.
|
* its own, so it can land inside a lead's labelled tab and, while its pane carries no entry in
|
||||||
|
* the spawned-member roster, be read back as that lead.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void aPanePlacedProfileWithALeadTabRefusesToStart(@TempDir Path dir) throws Exception {
|
void aPanePlacedProfileWithALeadTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
@@ -792,8 +923,12 @@ class FleetConfigTest {
|
|||||||
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
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
|
@Test
|
||||||
void aPanePlacedProfileWithNoLeadTabIsAllowed(@TempDir Path dir) throws Exception {
|
void aPanePlacedProfileWithNoLeadTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
Path f = dir.resolve("pane-no-tab.yaml");
|
Path f = dir.resolve("pane-no-tab.yaml");
|
||||||
Files.writeString(f, """
|
Files.writeString(f, """
|
||||||
bind:
|
bind:
|
||||||
@@ -806,9 +941,59 @@ class FleetConfigTest {
|
|||||||
opus:
|
opus:
|
||||||
profile: gx10
|
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(),
|
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
|
@Test
|
||||||
@@ -919,6 +1104,274 @@ class FleetConfigTest {
|
|||||||
"no primary.terminal pin ⇒ nothing registered, even with fleet.leaders configured");
|
"no primary.terminal pin ⇒ nothing registered, even with fleet.leaders configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #669: the collaborators registry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code Fleet} is {@code @JsonIgnoreProperties(ignoreUnknown = true)}, so a config naming
|
||||||
|
* {@code fleet.collaborators.<name>.tab} loads with no exception whether or not the key is
|
||||||
|
* ever read into the object model. Asserting only "no exception" would pass both before and
|
||||||
|
* after the real fix, so this asserts the parsed value is actually reachable from the loaded
|
||||||
|
* {@code FleetConfig} — the one thing a vacuous "no exception" test cannot tell apart.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void collaboratorsBlockIsActuallyParsedNotSilentlyDropped(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborators.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
reviewer-alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
assertEquals("collab: alex", cfg.fleet().collaborators().get("reviewer-alex").tab());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A collaborator carries no field other than {@code tab}, so a blank one is meaningless. */
|
||||||
|
@Test
|
||||||
|
void aCollaboratorWithNoTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("useless-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
ghost: {}
|
||||||
|
""");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #aCollaboratorWithNoTabRefusesToStart}: a named tab loads cleanly. */
|
||||||
|
@Test
|
||||||
|
void aCollaboratorWithATabIsAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("named-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
reviewer-alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: the fleet-wide {@code tabLabel} template can render as a collaborator tab, the
|
||||||
|
* same hazard {@link #aFleetTabLabelTemplateThatCanRenderAsALeadTabRefusesToStart} covers on
|
||||||
|
* the lead side. Drives the fleet-wide branch directly, with no profile override involved.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aFleetTabLabelTemplateThatCanRenderAsACollaboratorTabRefusesToStart(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("collide-template-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
tabLabel: "al{profile}"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "alpha"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("fleet.tabLabel"));
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name the offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A member tabLabel that can render as a configured collaborator tab is the same hazard as the
|
||||||
|
* lead case above — while its pane carries no entry in the spawned-member roster, a member
|
||||||
|
* labelled that way is read back as the collaborator.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aProfileTabLabelOverrideMatchingACollaboratorTabRefusesToStart(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("collide-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
tabLabel: "collab-tab"
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab-tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||||
|
assertTrue(e.getMessage().contains("collab-tab"), "the message must name the offending label");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control: a profile tabLabel that cannot render as the collaborator tab is allowed. */
|
||||||
|
@Test
|
||||||
|
void aProfileTabLabelThatCannotRenderAsACollaboratorTabIsAllowed(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("ok-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
tabLabel: "worker-{profile}"
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab-tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fleetd #669: identity is matched on a collaborator's exact tab, so two sharing one are unreachable. */
|
||||||
|
@Test
|
||||||
|
void twoCollaboratorsSharingTheSameExactTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("shared-collaborator-tab.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "shared tab"
|
||||||
|
sam:
|
||||||
|
tab: "Shared Tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name one offending collaborator");
|
||||||
|
assertTrue(e.getMessage().contains("sam"), "the message must name the other offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #twoCollaboratorsSharingTheSameExactTabRefusesToStart}: distinct tabs load cleanly. */
|
||||||
|
@Test
|
||||||
|
void twoCollaboratorsWithDistinctExactTabsAreAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("distinct-collaborator-tabs.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "alex tab"
|
||||||
|
sam:
|
||||||
|
tab: "sam tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: a collaborator tab equal to a lead tab crosses a privilege boundary — the worst
|
||||||
|
* of the three new collisions, since only one of the two identities is ever found.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorTabEqualToALeadTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("lead-collaborator-collision.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "shared tab"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "Shared Tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("opus"), "the message must name the offending lead");
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name the offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #aCollaboratorTabEqualToALeadTabRefusesToStart}: distinct tabs load cleanly. */
|
||||||
|
@Test
|
||||||
|
void aLeadAndACollaboratorWithDistinctTabsAreAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("lead-collaborator-ok.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "lead tab"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: a pane-placed member can land in a collaborator's labelled tab exactly as it
|
||||||
|
* can land in a lead's — {@code validatePanePlacementAgainstLeadTabs()} must fire even when
|
||||||
|
* {@code fleet.leaders} is empty, which is the early-return the brief flagged as the bug.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aPanePlacedProfileWithACollaboratorTabRefusesToStartEvenWithNoLeaders(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("pane-hazard-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
placement: pane
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class,
|
||||||
|
cfg::validatePanePlacementAgainstLeadTabs);
|
||||||
|
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control: a pane-placed profile with no lead or collaborator tab configured is allowed. */
|
||||||
|
@Test
|
||||||
|
void aPanePlacedProfileWithNoLeaderOrCollaboratorTabIsAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("pane-no-tab-at-all.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
placement: pane
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex: {}
|
||||||
|
""");
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> FleetConfig.load(f).validatePanePlacementAgainstLeadTabs(),
|
||||||
|
"a collaborator with no tab feeds nothing into the scanner, so pane placement is safe");
|
||||||
|
}
|
||||||
|
|
||||||
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
|
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1191,6 +1644,60 @@ class FleetConfigTest {
|
|||||||
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
|
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A duplicated name in {@code fleet.collaborators} is refused at parse time, like any other
|
||||||
|
* {@code fleet:} pool. See {@link #duplicateSlotNamesInOnePoolAreRejectedAtParseTime}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void duplicateCollaboratorNamesAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborator-dup.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex, second"
|
||||||
|
""");
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, () -> FleetConfig.load(f));
|
||||||
|
assertTrue(e.getMessage().contains("alex"),
|
||||||
|
"the refusal names the duplicated entry, was: " + e.getMessage());
|
||||||
|
assertTrue(e.getMessage().contains("fleet.collaborators"),
|
||||||
|
"the refusal names the pool the duplicate is in, was: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control for {@link #duplicateCollaboratorNamesAreRejectedAtParseTime}: the same name reused
|
||||||
|
* across the collaborators registry and a member role pool is the role × profile matrix doing
|
||||||
|
* its job in the other pool, not a mistake — only a repeat within one pool loses an entry.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theSameNameInCollaboratorsAndAnotherPoolIsNotADuplicate(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborator-cross-pool.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
sonnet:
|
||||||
|
baseUrl: http://gx10.gw:8000
|
||||||
|
fleet:
|
||||||
|
architects:
|
||||||
|
alex:
|
||||||
|
profile: sonnet
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
|
||||||
|
FleetConfig cfg = assertDoesNotThrow(() -> FleetConfig.load(f));
|
||||||
|
assertEquals(Set.of("alex"), cfg.fleet().pool(MemberRole.ARCHITECT).keySet());
|
||||||
|
assertEquals(Set.of("alex"), cfg.fleet().collaborators().keySet());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
|
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
|
||||||
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this
|
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this
|
||||||
@@ -3175,4 +3682,41 @@ class FleetConfigTest {
|
|||||||
FleetConfig cfg = FleetConfig.load(f);
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
assertTrue(cfg.models().offIds().isEmpty());
|
assertTrue(cfg.models().offIds().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #651: leadRollover.turnSettleSeconds default resolution ─────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void turnSettleSecondsDefaultsTo300WhenUnset(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("bare-rollover.yaml");
|
||||||
|
Files.writeString(f, "bind:\n port: 8080\nleadRollover: {}\n");
|
||||||
|
|
||||||
|
FleetConfig.LeadRollover rollover = FleetConfig.load(f).leadRollover();
|
||||||
|
assertNotNull(rollover);
|
||||||
|
assertEquals(300, rollover.turnSettleSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void turnSettleSecondsUsesAnExplicitPositiveValue(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("rollover.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
leadRollover:
|
||||||
|
turnSettleSeconds: 45
|
||||||
|
""");
|
||||||
|
|
||||||
|
FleetConfig.LeadRollover rollover = FleetConfig.load(f).leadRollover();
|
||||||
|
assertEquals(45, rollover.turnSettleSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void turnSettleSecondsFallsBackTo300WhenZeroOrNegative(@TempDir Path dir) throws Exception {
|
||||||
|
Path zero = dir.resolve("zero.yaml");
|
||||||
|
Files.writeString(zero, "bind:\n port: 8080\nleadRollover:\n turnSettleSeconds: 0\n");
|
||||||
|
assertEquals(300, FleetConfig.load(zero).leadRollover().turnSettleSeconds());
|
||||||
|
|
||||||
|
Path negative = dir.resolve("negative.yaml");
|
||||||
|
Files.writeString(negative, "bind:\n port: 8080\nleadRollover:\n turnSettleSeconds: -5\n");
|
||||||
|
assertEquals(300, FleetConfig.load(negative).leadRollover().turnSettleSeconds());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,63 +17,21 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The gap this class exists to close: mutation testing on the fleetd ticket "central allow-list
|
* Tests the reflective validator sweep and {@link FleetConfig#validateAll()} reachability.
|
||||||
* of usable models" found that although {@link FleetConfig#validateModels()}'s own logic was well
|
|
||||||
* pinned, nothing proved either real caller ({@code Fleetd.main} and {@link ConfigRef#reload()})
|
|
||||||
* still invoked it — deleting the call site left the full suite green (1478/0/0/0). A follow-up
|
|
||||||
* measurement (same technique — remove one call site, run the suite, not read the code) found the
|
|
||||||
* SAME gap for all five of {@link FleetConfig}'s other validators at startup, and for four of the
|
|
||||||
* six inside {@link ConfigRef#reload()}. This is a class of gap, not one line's mistake: every one
|
|
||||||
* of those thirteen tests called the validator itself directly, never the real caller that was
|
|
||||||
* supposed to.
|
|
||||||
*
|
*
|
||||||
* <p>The fix replaces the six individual {@code cfg.validateXxx()} calls at each of the two real
|
* <p>{@link #theSweepRunsEveryValidateMethodOnAnUnrelatedClass()} and its neighbours
|
||||||
* call sites with one {@link FleetConfig#validateAll()}, which reaches every validator by
|
* prove that {@link FleetConfig#invokeAllValidators} runs each public, no-arg, void
|
||||||
* reflection rather than by a hand-maintained list of names. A hand-maintained list of six names
|
* {@code validateXxx()} method on its target. {@link #fleetConfigDeclaresExactlyTheseValidatorsToday()}
|
||||||
* would have exactly the defect it replaces: the seventh validator someone adds next month has no
|
* is the canary for the validator set. {@link #validateAllReachesEveryOneOfTodaysRealValidators()}
|
||||||
* reason to be added to it, and nothing would say so. This class proves TWO separate claims, and
|
* is the reachability check for that set.
|
||||||
* keeps them separate on purpose:
|
|
||||||
*
|
|
||||||
* <ol>
|
|
||||||
* <li>{@link #theSweepMechanismIsGenericNotHardcodedToFleetConfigsSixNames()} and its neighbours
|
|
||||||
* prove the reflective sweep itself ({@link FleetConfig#invokeAllValidators}) is a general
|
|
||||||
* mechanism — it runs whatever public, no-arg, void {@code validateXxx()} methods a class
|
|
||||||
* happens to declare today, including a class with more of them than {@link FleetConfig}
|
|
||||||
* has right now. This is the proof that a future, real seventh validator on {@link
|
|
||||||
* FleetConfig} would be swept automatically, without needing to add a real (unwanted)
|
|
||||||
* seventh validator just to exercise the claim.</li>
|
|
||||||
* <li>{@link #validateAllReachesEveryOneOfTodaysRealValidators()} proves {@link
|
|
||||||
* FleetConfig#validateAll()} itself is wired to that same generic mechanism and genuinely
|
|
||||||
* reaches seven of today's eight real validators — reusing the exact minimal failing
|
|
||||||
* configurations {@code FleetConfigTest} already established for each one directly, so a
|
|
||||||
* single call to {@code validateAll()} is shown to reproduce every one of those seven
|
|
||||||
* failures. The eighth, {@link FleetConfig#validateLeadRollover()}, has no case here yet —
|
|
||||||
* a pre-existing gap tracked as fleetd #668.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p>Together with the direct-{@code Fleetd.main}-invocation tests in {@code
|
|
||||||
* FleetdStartupValidationTest} (which prove the real startup call site still calls {@code
|
|
||||||
* validateAll()}) and the {@code ConfigRefTest} reload tests (which prove the same for {@link
|
|
||||||
* ConfigRef#reload()}), removing {@code cfg.validateAll();} from either real call site now fails
|
|
||||||
* a test in this module.
|
|
||||||
*
|
|
||||||
* <p><b>What is NOT pinned, measured rather than assumed.</b> Reverting {@link
|
|
||||||
* FleetConfig#validateAll()} to a hardcoded list of today's six method calls leaves the whole
|
|
||||||
* suite green (measured at review: 1491 tests, 0 failures). Nothing ties {@code validateAll()} to
|
|
||||||
* the generic sweep — claim 1 proves {@link FleetConfig#invokeAllValidators} is generic, and claim
|
|
||||||
* 2 proves {@code validateAll()} reaches today's six, and a hardcoded list satisfies both. So the
|
|
||||||
* reflective sweep is a convenience, not the guarantee. The guarantee is {@link
|
|
||||||
* #fleetConfigDeclaresExactlyTheseValidatorsToday()}: it fails the moment any validator is added
|
|
||||||
* or removed, which forces whoever changes the set to look at this file.
|
|
||||||
*/
|
*/
|
||||||
class FleetConfigValidateAllTest {
|
class FleetConfigValidateAllTest {
|
||||||
|
|
||||||
// ── Claim 1: the reflective sweep is a general mechanism, not six names in disguise ──────────
|
// ── Claim 1: the reflective sweep is a general mechanism ─────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A throwaway fixture class, unrelated to {@link FleetConfig} in every way except shape: three
|
* Fixture with public, no-arg, void methods named {@code validateXxx}. It proves the sweep uses
|
||||||
* public, no-arg, void methods named {@code validateXxx}. Proves the sweep works on ANY class
|
* the target's method shape rather than special handling for {@link FleetConfig}.
|
||||||
* with this shape, not on something special-cased to {@link FleetConfig}.
|
|
||||||
*/
|
*/
|
||||||
static class ThreeValidators {
|
static class ThreeValidators {
|
||||||
final List<String> ran = new ArrayList<>();
|
final List<String> ran = new ArrayList<>();
|
||||||
@@ -92,7 +50,7 @@ class FleetConfigValidateAllTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void theSweepMechanismIsGenericNotHardcodedToFleetConfigsSixNames() {
|
void theSweepRunsEveryValidateMethodOnAnUnrelatedClass() {
|
||||||
ThreeValidators target = new ThreeValidators();
|
ThreeValidators target = new ThreeValidators();
|
||||||
FleetConfig.invokeAllValidators(target);
|
FleetConfig.invokeAllValidators(target);
|
||||||
assertEquals(List.of("validateAlpha", "validateBeta", "validateGamma"), target.ran,
|
assertEquals(List.of("validateAlpha", "validateBeta", "validateGamma"), target.ran,
|
||||||
@@ -102,12 +60,8 @@ class FleetConfigValidateAllTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The core of the "self-maintaining" requirement: the exact same class shape as {@link
|
* Fixture with an added valid method. It proves the sweep reaches a method because it matches
|
||||||
* ThreeValidators}, plus one more method — standing in for "a developer adds a validator next
|
* the validator shape.
|
||||||
* month". Nothing about the sweep changes to pick it up; the new method is invoked purely
|
|
||||||
* because it exists and matches the shape. This is what makes adding a seventh real validator
|
|
||||||
* to {@link FleetConfig} safe without touching {@link FleetConfig#validateAll()} or either
|
|
||||||
* call site — there is no "wire it in" step left to forget.
|
|
||||||
*/
|
*/
|
||||||
static class FourValidators {
|
static class FourValidators {
|
||||||
final List<String> ran = new ArrayList<>();
|
final List<String> ran = new ArrayList<>();
|
||||||
@@ -135,7 +89,7 @@ class FleetConfigValidateAllTest {
|
|||||||
FleetConfig.invokeAllValidators(target);
|
FleetConfig.invokeAllValidators(target);
|
||||||
assertEquals(List.of("validateAlpha", "validateBeta", "validateDelta", "validateGamma"),
|
assertEquals(List.of("validateAlpha", "validateBeta", "validateDelta", "validateGamma"),
|
||||||
sorted(target.ran),
|
sorted(target.ran),
|
||||||
"the fourth method must be reached automatically — proving a class can grow the "
|
"the added method must be reached automatically — proving a class can grow the "
|
||||||
+ "set of things it validates with no change to the sweep itself");
|
+ "set of things it validates with no change to the sweep itself");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +164,7 @@ class FleetConfigValidateAllTest {
|
|||||||
+ "name) must all be skipped");
|
+ "name) must all be skipped");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Claim 2: FleetConfig.validateAll() is wired to that mechanism and reaches seven of eight today ──
|
// ── Claim 2: FleetConfig.validateAll() is wired to that mechanism and reaches every validator ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reflectively enumerates {@link FleetConfig}'s own public, no-arg, void {@code validateXxx()}
|
* Reflectively enumerates {@link FleetConfig}'s own public, no-arg, void {@code validateXxx()}
|
||||||
@@ -220,6 +174,11 @@ class FleetConfigValidateAllTest {
|
|||||||
* the {@code Set.of} below, so a reader adding or removing one sees this assertion name the new
|
* the {@code Set.of} below, so a reader adding or removing one sees this assertion name the new
|
||||||
* count rather than a silent pass at the old one. The count lives only in that set, not in this
|
* count rather than a silent pass at the old one. The count lives only in that set, not in this
|
||||||
* method's name, so the two cannot drift apart.
|
* method's name, so the two cannot drift apart.
|
||||||
|
*
|
||||||
|
* <p>This assertion alone proves only that the validator exists with the right shape — it
|
||||||
|
* cannot prove {@code validateAll()} actually reaches it. Only {@link
|
||||||
|
* #validateAllReachesEveryOneOfTodaysRealValidators()} proves reachability, which is why this
|
||||||
|
* method's failure message sends the reader there too.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void fleetConfigDeclaresExactlyTheseValidatorsToday() {
|
void fleetConfigDeclaresExactlyTheseValidatorsToday() {
|
||||||
@@ -237,11 +196,16 @@ class FleetConfigValidateAllTest {
|
|||||||
"validateSubscriptionProfiles", "validateCharters", "validateMembers",
|
"validateSubscriptionProfiles", "validateCharters", "validateMembers",
|
||||||
"validateModels", "validateLeadRollover", "validatePanePlacementAgainstLeadTabs")),
|
"validateModels", "validateLeadRollover", "validatePanePlacementAgainstLeadTabs")),
|
||||||
names,
|
names,
|
||||||
"FleetConfig's public validate*() methods changed. Do TWO things, in this "
|
"FleetConfig's public validate*() methods changed. Do THREE things, in this "
|
||||||
+ "order. First confirm validateAll() still delegates to "
|
+ "order. First confirm validateAll() still delegates to "
|
||||||
+ "invokeAllValidators(this) — a hardcoded list there passes every other "
|
+ "invokeAllValidators(this) — a hardcoded list there passes every other "
|
||||||
+ "test in this class, so this assertion is the only place that will ever "
|
+ "test in this class, so this assertion is the only place that will ever "
|
||||||
+ "make you check. Only then update the expected set to match.");
|
+ "make you check. Second, update the expected set below to match. Third, "
|
||||||
|
+ "add or remove a case for that validator in "
|
||||||
|
+ "validateAllReachesEveryOneOfTodaysRealValidators() below — this "
|
||||||
|
+ "assertion proves only that the validator exists with the right shape, "
|
||||||
|
+ "never that validateAll() reaches it; that enumeration is the test that "
|
||||||
|
+ "does.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A minimal, otherwise-valid file — same shape FleetConfigTest and ConfigRefTest use. */
|
/** A minimal, otherwise-valid file — same shape FleetConfigTest and ConfigRefTest use. */
|
||||||
@@ -265,14 +229,18 @@ class FleetConfigValidateAllTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The heart of claim 2: for seven of today's eight real validators, a minimal file that fails
|
* The heart of claim 2: for every one of today's real validators, a minimal file that
|
||||||
* ONLY that one — the exact fixtures {@code FleetConfigTest} uses to test each validator
|
* fails ONLY that one — the exact fixtures {@code FleetConfigTest} uses to test each validator
|
||||||
* directly — must also fail through {@link FleetConfig#validateAll()}. If a future edit to
|
* directly, or a dedicated minimal fixture where no other test drives that validator through
|
||||||
* {@code validateAll()} silently dropped one of these seven from the sweep (e.g. a typo'd name
|
* {@code validateAll()} — must also fail through {@link FleetConfig#validateAll()}. If a
|
||||||
* filter), exactly one of them would start passing when it must not.
|
* future edit to {@code validateAll()} silently dropped one of these from the sweep
|
||||||
|
* (e.g. a typo'd name filter), exactly one of them would start passing when it must not.
|
||||||
*
|
*
|
||||||
* <p>The eighth, {@link FleetConfig#validateLeadRollover()}, has no case here — a pre-existing
|
* <p>This is the single place that proves {@code validateAll()} reaches a given validator.
|
||||||
* gap tracked as fleetd #668, not fixed by this change.
|
* Adding or removing a validator on {@link FleetConfig} must add or remove a case here, not
|
||||||
|
* only an updated name in {@link #fleetConfigDeclaresExactlyTheseValidatorsToday()}'s expected
|
||||||
|
* set — that assertion proves the validator's shape, never that {@code validateAll()} reaches
|
||||||
|
* it.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void validateAllReachesEveryOneOfTodaysRealValidators(@TempDir Path dir) throws Exception {
|
void validateAllReachesEveryOneOfTodaysRealValidators(@TempDir Path dir) throws Exception {
|
||||||
@@ -283,16 +251,16 @@ class FleetConfigValidateAllTest {
|
|||||||
port: 8765
|
port: 8765
|
||||||
""", "auth.mode: token");
|
""", "auth.mode: token");
|
||||||
|
|
||||||
// validateLeadTabPrefixes: a fleet-wide tabLabel that starts with a lead's own tabPrefix.
|
// validateLeadTabPrefixes: a fleet-wide tabLabel that equals a lead tab.
|
||||||
assertValidateAllRefuses(dir, "lead-tab-prefixes.yaml", """
|
assertValidateAllRefuses(dir, "lead-tab-prefixes.yaml", """
|
||||||
bind:
|
bind:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8765
|
port: 8765
|
||||||
fleet:
|
fleet:
|
||||||
tabLabel: "lead: {role} {profile}"
|
tabLabel: "alpha"
|
||||||
leaders:
|
leaders:
|
||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "alpha"
|
||||||
""", "fleet.tabLabel");
|
""", "fleet.tabLabel");
|
||||||
|
|
||||||
// validateSubscriptionProfiles: subscription: true with env: reseating ANTHROPIC_BASE_URL.
|
// validateSubscriptionProfiles: subscription: true with env: reseating ANTHROPIC_BASE_URL.
|
||||||
@@ -362,6 +330,15 @@ class FleetConfigValidateAllTest {
|
|||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "lead: opus"
|
||||||
""", "gx10");
|
""", "gx10");
|
||||||
|
|
||||||
|
// validateLeadRollover: a leadRollover: block present with no handoverPath.
|
||||||
|
assertValidateAllRefuses(dir, "lead-rollover.yaml", """
|
||||||
|
bind:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8765
|
||||||
|
leadRollover:
|
||||||
|
requireOperatorConfirm: false
|
||||||
|
""", "handoverPath");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertValidateAllRefuses(Path dir, String fileName, String yaml,
|
private static void assertValidateAllRefuses(Path dir, String fileName, String yaml,
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ class FleetConfigWithDefaultsPreservesEveryComponentTest {
|
|||||||
// comment there), same as broker/primary/leadHeartbeat/... above — a real, non-null value
|
// comment there), same as broker/primary/leadHeartbeat/... above — a real, non-null value
|
||||||
// here proves it, rather than leaving it null and proving nothing.
|
// here proves it, rather than leaving it null and proving nothing.
|
||||||
v.put("leadRollover", new FleetConfig.LeadRollover(
|
v.put("leadRollover", new FleetConfig.LeadRollover(
|
||||||
"/handover/guard.md", true, 3600, 20, 20, "read the handover file"));
|
"/handover/guard.md", true, 3600, 20, 45, "read the handover file"));
|
||||||
assertNamesMatchComponents(v);
|
assertNamesMatchComponents(v);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
@@ -23,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}. */
|
/** The foreground PID of the one agent pane (term_a) in the canned {@code pane.process_info}. */
|
||||||
public static final long WORKER_PID = 4242;
|
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();
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
/**
|
/**
|
||||||
* Thread-safe on purpose. Background loops — {@link dev.ltms.fleet.msg.ReplyPushLoop} and the
|
* Thread-safe on purpose. Background loops — {@link dev.ltms.fleet.msg.ReplyPushLoop} and the
|
||||||
@@ -47,18 +83,25 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
private final Map<String, String> processInfoErrorCodeFor = new ConcurrentHashMap<>();
|
private final Map<String, String> processInfoErrorCodeFor = new ConcurrentHashMap<>();
|
||||||
private String tabCloseErrorCode = null;
|
private String tabCloseErrorCode = null;
|
||||||
private final Map<String, String> tabCloseErrorCodeFor = new ConcurrentHashMap<>();
|
private final Map<String, String> tabCloseErrorCodeFor = new ConcurrentHashMap<>();
|
||||||
|
private String workspaceListErrorCode = null;
|
||||||
private String agentSendErrorCode = null;
|
private String agentSendErrorCode = null;
|
||||||
private boolean noPanes = false;
|
private boolean noPanes = false;
|
||||||
private volatile String agentStatus = "idle"; // steady-state agent.get status
|
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 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 agentSessionId = null; // agent_session.value on agent.get; null = omitted
|
||||||
private volatile String readText = "worker transcript tail"; // canned agent.read output
|
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 int pinnedStarts = 0; // how many upcoming agent.start calls report a fixed pane
|
||||||
private String pinnedStartTerminal;
|
private String pinnedStartTerminal;
|
||||||
private String pinnedStartPane;
|
private String pinnedStartPane;
|
||||||
private Runnable onAgentStart; // fires the instant agent.start is called — see onAgentStart(Runnable)
|
private Runnable onAgentStart; // fires the instant agent.start is called — see onAgentStart(Runnable)
|
||||||
private volatile int agentGetOkCalls = Integer.MAX_VALUE; // how many agent.get calls succeed first
|
private volatile int agentGetOkCalls = Integer.MAX_VALUE; // how many agent.get calls succeed first
|
||||||
private volatile String agentGetFailCode = null; // error code every agent.get call after that reports
|
private volatile String agentGetFailCode = null; // error code every agent.get call after that reports
|
||||||
|
/** pane ids that {@link #paneGoneAfterClose} has opted into reporting gone — see that method. */
|
||||||
|
private final Set<String> paneGoneAfterCloseIds = ConcurrentHashMap.newKeySet();
|
||||||
|
/** pane ids a {@code pane.close} call has actually reached, for {@link #paneGoneAfterCloseIds}. */
|
||||||
|
private final Set<String> closedPaneIds = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public FakeHerdr healthy(boolean h) {
|
public FakeHerdr healthy(boolean h) {
|
||||||
this.healthy = h;
|
this.healthy = h;
|
||||||
@@ -137,6 +180,12 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
return this;
|
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
|
* 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.
|
* simply does not host the pane a {@link PaneLocator} is searching for.
|
||||||
@@ -195,12 +244,27 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
return this;
|
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) {
|
public FakeHerdr readText(String text) {
|
||||||
this.readText = text;
|
this.readText = text;
|
||||||
return this;
|
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. */
|
/** Make delivery ({@code agent.prompt} / {@code agent.send_keys}) fail with this error code. */
|
||||||
public FakeHerdr agentSendFailsWith(String code) {
|
public FakeHerdr agentSendFailsWith(String code) {
|
||||||
this.agentSendErrorCode = code;
|
this.agentSendErrorCode = code;
|
||||||
@@ -220,6 +284,19 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make {@code pane.get(paneId)} report the pane gone (a {@code pane_not_found} {@link
|
||||||
|
* HerdrException}, exactly as {@link WorkspaceControl#locatePane} expects to see once a pane
|
||||||
|
* has really disappeared) once a {@code pane.close} call for that same {@code paneId} has
|
||||||
|
* actually reached this fake. Every other pane, and this pane before its own close, keeps
|
||||||
|
* reporting the default canned {@code pane.get} response — opt-in, by pane id, so no existing
|
||||||
|
* test's {@code pane.get} behaviour changes.
|
||||||
|
*/
|
||||||
|
public FakeHerdr paneGoneAfterClose(String paneId) {
|
||||||
|
paneGoneAfterCloseIds.add(paneId);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run {@code hook} synchronously the instant an {@code agent.start} call reaches this fake —
|
* Run {@code hook} synchronously the instant an {@code agent.start} call reaches this fake —
|
||||||
* i.e. the instant the peer PROCESS would start against a real herdr daemon. A test uses this
|
* i.e. the instant the peer PROCESS would start against a real herdr daemon. A test uses this
|
||||||
@@ -284,11 +361,17 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
case "ping" -> mapper.readTree(
|
case "ping" -> mapper.readTree(
|
||||||
("{\"type\":\"pong\",\"version\":\"%s\",\"protocol\":%d}")
|
("{\"type\":\"pong\",\"version\":\"%s\",\"protocol\":%d}")
|
||||||
.formatted(pingVersion, pingProtocol));
|
.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":[
|
{"type":"workspace_list","workspaces":[
|
||||||
{"workspace_id":"w1","label":"dev-mgnl","focused":true,"pane_count":7,"agent_status":"unknown"},
|
{"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]}""")
|
{"workspace_id":"w2","label":"ltms","focused":false,"pane_count":5,"agent_status":"done"}%s]}""")
|
||||||
.formatted(extraWorkspaces.isEmpty() ? "" : "," + String.join(",", extraWorkspaces)));
|
.formatted(extraWorkspaces.isEmpty() ? "" : "," + String.join(",", extraWorkspaces)));
|
||||||
|
}
|
||||||
case "agent.list" -> mapper.readTree(("""
|
case "agent.list" -> mapper.readTree(("""
|
||||||
{"type":"agent_list","agents":[
|
{"type":"agent_list","agents":[
|
||||||
{"terminal_id":"term_a","agent":"claude","agent_status":"idle",
|
{"terminal_id":"term_a","agent":"claude","agent_status":"idle",
|
||||||
@@ -329,8 +412,13 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
"agent_status":"%s","workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"%s}}""")
|
"agent_status":"%s","workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"%s}}""")
|
||||||
.formatted(agentField, agentStatus, sessionField));
|
.formatted(agentField, agentStatus, sessionField));
|
||||||
}
|
}
|
||||||
case "agent.read" -> mapper.readTree(mapper.writeValueAsString(
|
case "agent.read" -> {
|
||||||
java.util.Map.of("type", "agent_read", "read", java.util.Map.of("text", readText))));
|
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" -> {
|
case "agent.start" -> {
|
||||||
if (onAgentStart != null) {
|
if (onAgentStart != null) {
|
||||||
onAgentStart.run();
|
onAgentStart.run();
|
||||||
@@ -422,9 +510,18 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
}
|
}
|
||||||
yield mapper.readTree("{\"type\":\"ok\"}");
|
yield mapper.readTree("{\"type\":\"ok\"}");
|
||||||
}
|
}
|
||||||
case "pane.get" -> mapper.readTree("""
|
case "pane.get" -> {
|
||||||
|
Object paneIdParam = params instanceof Map<?, ?> m ? m.get("pane_id") : null;
|
||||||
|
String paneIdKey = paneIdParam == null ? null : String.valueOf(paneIdParam);
|
||||||
|
if (paneIdKey != null && paneGoneAfterCloseIds.contains(paneIdKey)
|
||||||
|
&& closedPaneIds.contains(paneIdKey)) {
|
||||||
|
throw new HerdrException("herdr error [pane_not_found]: pane.get failed",
|
||||||
|
"pane_not_found", null);
|
||||||
|
}
|
||||||
|
yield mapper.readTree("""
|
||||||
{"type":"pane_info","pane":{"pane_id":"w9:pW","workspace_id":"w9",
|
{"type":"pane_info","pane":{"pane_id":"w9:pW","workspace_id":"w9",
|
||||||
"tab_id":"w9:t2","agent_status":"idle"}}""");
|
"tab_id":"w9:t2","agent_status":"idle"}}""");
|
||||||
|
}
|
||||||
case "pane.list" -> noPanes
|
case "pane.list" -> noPanes
|
||||||
? mapper.readTree("{\"type\":\"pane_list\",\"panes\":[]}")
|
? mapper.readTree("{\"type\":\"pane_list\",\"panes\":[]}")
|
||||||
: mapper.readTree("""
|
: mapper.readTree("""
|
||||||
@@ -458,6 +555,9 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
throw new HerdrException("herdr error [" + code + "]: pane.close failed",
|
throw new HerdrException("herdr error [" + code + "]: pane.close failed",
|
||||||
code, null);
|
code, null);
|
||||||
}
|
}
|
||||||
|
if (paneIdParam != null) {
|
||||||
|
closedPaneIds.add(String.valueOf(paneIdParam));
|
||||||
|
}
|
||||||
yield mapper.readTree("{\"type\":\"ok\"}");
|
yield mapper.readTree("{\"type\":\"ok\"}");
|
||||||
}
|
}
|
||||||
default -> throw new HerdrException("fake has no canned response for " + method);
|
default -> throw new HerdrException("fake has no canned response for " + method);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package dev.ltms.fleet.herdr;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||||
|
|
||||||
@@ -28,4 +30,44 @@ class HerdrRouterTest {
|
|||||||
assertSame(router.leadAgents(), router.agentsFor("lead"));
|
assertSame(router.leadAgents(), router.agentsFor("lead"));
|
||||||
assertSame(router.memberAgents(), router.agentsFor("member"));
|
assertSame(router.memberAgents(), router.agentsFor("member"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit E. The predicate shape here is exactly what {@code FleetdAssembly} builds:
|
||||||
|
* true when the terminal is a known lead OR a known collaborator. Two distinct clients are
|
||||||
|
* required — with one shared client {@code agentsFor} would return the same object regardless
|
||||||
|
* of the predicate's answer, and this assertion would pass whether or not the collaborator map
|
||||||
|
* was ever consulted.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void collaboratorTerminalRoutesToTheLeadDaemon() {
|
||||||
|
FakeHerdr lead = new FakeHerdr();
|
||||||
|
FakeHerdr member = new FakeHerdr();
|
||||||
|
Map<String, String> leads = Map.of("term_lead", "primary");
|
||||||
|
Map<String, String> collaborators = Map.of("term_collab", "reviewer-alex");
|
||||||
|
HerdrRouter router = new HerdrRouter(lead, member,
|
||||||
|
id -> leads.containsKey(id) || collaborators.containsKey(id));
|
||||||
|
|
||||||
|
assertSame(router.leadAgents(), router.agentsFor("term_collab"),
|
||||||
|
"a configured collaborator's terminal must route to the LEAD daemon, not the member "
|
||||||
|
+ "one — its pane is opened by a person, exactly like a lead's");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Companion to {@link #collaboratorTerminalRoutesToTheLeadDaemon}: a terminal that is neither a
|
||||||
|
* known lead nor a known collaborator must still route to the member daemon. Without this, a
|
||||||
|
* predicate of {@code _ -> true} would also pass the test above.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void terminalInNeitherMapStillRoutesToTheMemberDaemon() {
|
||||||
|
FakeHerdr lead = new FakeHerdr();
|
||||||
|
FakeHerdr member = new FakeHerdr();
|
||||||
|
Map<String, String> leads = Map.of("term_lead", "primary");
|
||||||
|
Map<String, String> collaborators = Map.of("term_collab", "reviewer-alex");
|
||||||
|
HerdrRouter router = new HerdrRouter(lead, member,
|
||||||
|
id -> leads.containsKey(id) || collaborators.containsKey(id));
|
||||||
|
|
||||||
|
assertSame(router.memberAgents(), router.agentsFor("term_worker"),
|
||||||
|
"a terminal absent from both maps must stay on the member daemon — the fix widens "
|
||||||
|
+ "the predicate, it does not make it unconditionally true");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,9 +158,25 @@ class LeadTabScannerTest {
|
|||||||
return Map.of("lead: opus-5.0", "opus-5.0", "lead: gpt-sol-5.6", "gpt-sol-5.6");
|
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,
|
private LeadTabScanner scanner(TopologyHerdr herdr, Map<String, String> tabToName,
|
||||||
AtomicLong clock) {
|
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, inSpace(MAIN_SPACE, tabToName), collaboratorTabToName,
|
||||||
|
Set.of("fleetd-workers"), TTL, clock::get);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -198,9 +214,12 @@ class LeadTabScannerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guard that matters: fleetd labels its own worker tabs, so if a worker space were scanned
|
* Covers the {@code excludedWorkspaceLabels} parameter: a tab in an excluded workspace is never
|
||||||
* a naming accident would promote the fleet. The exclusion is by workspace, not by hoping the
|
* matched, whatever its label. Production always constructs this class with an empty set (CB-558,
|
||||||
* worker template never collides.
|
* {@code FleetdAssembly}), so this parameter plays no part in the live guard against a worker
|
||||||
|
* tab being mistaken for a lead — that guard is {@code CallerResolver} asking the live
|
||||||
|
* spawned-member roster before any tab map. This test exists because the parameter still exists
|
||||||
|
* and is worth covering on its own terms.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void aTabInAWorkerSpaceIsNeverALeadEvenWhenItsLabelMatches() {
|
void aTabInAWorkerSpaceIsNeverALeadEvenWhenItsLabelMatches() {
|
||||||
@@ -212,6 +231,76 @@ class LeadTabScannerTest {
|
|||||||
assertFalse(scanner(herdr, tabToName, new AtomicLong()).get().containsKey("term_impostor"));
|
assertFalse(scanner(herdr, tabToName, new AtomicLong()).get().containsKey("term_impostor"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shipped shape (CB-558, {@code FleetdAssembly}): production always constructs this class
|
||||||
|
* with an empty {@code excludedWorkspaceLabels}, and a lead's {@code workspace:} default is the
|
||||||
|
* same shared {@code "fleet"} space the members use. A lead tab is still found when it sits in
|
||||||
|
* the exact same workspace as a member-labelled tab — the scanner tells them apart by the exact
|
||||||
|
* tab label, not by which workspace either one is in.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aLeadIsDiscoveredWhenItsWorkspaceIsTheSameAsTheMemberWorkspace() {
|
||||||
|
TopologyHerdr herdr = new TopologyHerdr()
|
||||||
|
.workspace("w1", "fleet")
|
||||||
|
.tab("w1:t1", "w1", "lead: opus-5.0")
|
||||||
|
.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,
|
||||||
|
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
|
@Test
|
||||||
void aLabelWithNoConfiguredEntryIsIgnored() {
|
void aLabelWithNoConfiguredEntryIsIgnored() {
|
||||||
TopologyHerdr herdr = new TopologyHerdr().workspace("w1", "main")
|
TopologyHerdr herdr = new TopologyHerdr().workspace("w1", "main")
|
||||||
@@ -464,4 +553,75 @@ class LeadTabScannerTest {
|
|||||||
|
|
||||||
assertEquals(afterFirst, herdr.calls, "the failure path must be rate-limited too");
|
assertEquals(afterFirst, herdr.calls, "the failure path must be rate-limited too");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #669 Unit D: a collaborator tab is matched the same way as a lead tab, one pass ─────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aConfiguredCollaboratorTabIsReportedByCollaboratorsNotByGet() {
|
||||||
|
TopologyHerdr herdr = new TopologyHerdr()
|
||||||
|
.workspace("w1", "main")
|
||||||
|
.tab("w1:t1", "w1", "collab: ops")
|
||||||
|
.pane("w1:p1", "w1:t1", "term_ops");
|
||||||
|
LeadTabScanner s = scannerWithCollaborators(herdr, Map.of(), Map.of("collab: ops", "ops"),
|
||||||
|
new AtomicLong());
|
||||||
|
|
||||||
|
assertEquals(Map.of("term_ops", "ops"), s.collaborators(),
|
||||||
|
"a collaborator tab is matched exactly like a lead tab");
|
||||||
|
assertEquals(Map.of(), s.get(), "a collaborator tab must never also appear as a lead");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Criterion 4, scanner level: a collaborator tab that is labelled but runs no agent is not
|
||||||
|
* reported — the same #359 liveness cross-check a lead tab gets.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aDeadCollaboratorTabIsNotReported() {
|
||||||
|
TopologyHerdr herdr = new TopologyHerdr()
|
||||||
|
.workspace("w1", "main")
|
||||||
|
.tab("w1:t1", "w1", "collab: ops")
|
||||||
|
.pane("w1:p1", "w1:t1", "term_ops")
|
||||||
|
.deadAgent("w1:t1");
|
||||||
|
LeadTabScanner s = scannerWithCollaborators(herdr, Map.of(), Map.of("collab: ops", "ops"),
|
||||||
|
new AtomicLong());
|
||||||
|
|
||||||
|
assertFalse(s.collaborators().containsKey("term_ops"),
|
||||||
|
"a dead collaborator tab must never resolve as a live collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both kinds are matched in a single pass over the same tab list — not a second scanner, not a
|
||||||
|
* second scan. Proven by herdr call count: scanning one lead tab and one collaborator tab in the
|
||||||
|
* same instance costs exactly as many calls as scanning two lead tabs in {@link #twoLeads()}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void leadsAndCollaboratorsAreMatchedInOnePassOverTheSameScan() {
|
||||||
|
TopologyHerdr oneOfEach = new TopologyHerdr()
|
||||||
|
.workspace("w1", "main")
|
||||||
|
.tab("w1:t1", "w1", "lead: opus-5.0")
|
||||||
|
.tab("w1:t2", "w1", "collab: ops")
|
||||||
|
.pane("w1:p1", "w1:t1", "term_opus")
|
||||||
|
.pane("w1:p2", "w1:t2", "term_ops");
|
||||||
|
LeadTabScanner s = scannerWithCollaborators(oneOfEach, Map.of("lead: opus-5.0", "opus-5.0"),
|
||||||
|
Map.of("collab: ops", "ops"), new AtomicLong());
|
||||||
|
|
||||||
|
assertEquals(Map.of("term_opus", "opus-5.0"), s.get());
|
||||||
|
assertEquals(Map.of("term_ops", "ops"), s.collaborators());
|
||||||
|
|
||||||
|
TopologyHerdr twoLeadsBaseline = twoLeads();
|
||||||
|
scanner(twoLeadsBaseline, twoLeadsConfigured(), new AtomicLong()).get();
|
||||||
|
|
||||||
|
assertEquals(twoLeadsBaseline.calls, oneOfEach.calls,
|
||||||
|
"one lead tab + one collaborator tab must cost exactly as many herdr calls as two "
|
||||||
|
+ "lead tabs — proof this is one pass, not a second scan");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regression: with no collaborators configured, every existing lead-only behaviour is unchanged. */
|
||||||
|
@Test
|
||||||
|
void anEmptyCollaboratorMapLeavesCollaboratorsEmptyAndGetUnaffected() {
|
||||||
|
LeadTabScanner s = scannerWithCollaborators(twoLeads(), twoLeadsConfigured(), Map.of(),
|
||||||
|
new AtomicLong());
|
||||||
|
|
||||||
|
assertEquals(Map.of(), s.collaborators());
|
||||||
|
assertEquals(Map.of("term_opus", "opus-5.0", "term_gpt", "gpt-sol-5.6"), s.get());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
return MAPPER.createObjectNode().set("agent", MAPPER.createObjectNode()
|
||||||
.put("terminal_id", "term_primary").put("agent_status", "idle"));
|
.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)) {
|
if ("agent.prompt".equals(method)) {
|
||||||
prompts.add(params);
|
prompts.add(params);
|
||||||
sendLatch.countDown();
|
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());
|
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
|
@Test
|
||||||
void holdsDeliveryUntilTheWorkerIsAvailable() {
|
void holdsDeliveryUntilTheWorkerIsAvailable() {
|
||||||
// CB-113: idle alone is not enough — hold until the worker's MCP is connected (ready).
|
// CB-113: idle alone is not enough — hold until the worker's MCP is connected (ready).
|
||||||
@@ -548,20 +559,21 @@ class InjectorTest {
|
|||||||
void readinessGraceExpiryLogsTheMeasuredElapsedTimeNotArithmeticOnConstants() {
|
void readinessGraceExpiryLogsTheMeasuredElapsedTimeNotArithmeticOnConstants() {
|
||||||
// fleetd #501, defect 2: the old line computed "({}s)" as READINESS_GRACE_POLLS *
|
// 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
|
// 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
|
// in the direction that says everything ran on schedule. This stub clock returns three FIXED
|
||||||
// values (1_000ms at the first non-ready sample, 318_412ms at the poll that trips the grace)
|
// values (an unused enqueue-time stamp, 1_000ms at the first non-ready sample, 318_412ms at
|
||||||
// whose difference — 317_412ms — does NOT equal 240 * POLL_INTERVAL_MILLIS (=60_000ms).
|
// the poll that trips the grace) whose last two differ — 317_412ms — which does NOT equal
|
||||||
// Asserting on that literal, non-derived number is what makes this test able to fail if the
|
// 240 * POLL_INTERVAL_MILLIS (=60_000ms). Asserting on that literal, non-derived number is
|
||||||
// production code goes back to printing the constant-arithmetic value instead of the
|
// what makes this test able to fail if the production code goes back to printing the
|
||||||
// injected clock's measurement.
|
// constant-arithmetic value instead of the injected clock's measurement.
|
||||||
long[] readings = {1_000L, 318_412L};
|
long[] readings = {0L, 1_000L, 318_412L};
|
||||||
AtomicInteger call = new AtomicInteger(0);
|
AtomicInteger call = new AtomicInteger(0);
|
||||||
LongSupplier stubClock = () -> {
|
LongSupplier stubClock = () -> {
|
||||||
int i = call.getAndIncrement();
|
int i = call.getAndIncrement();
|
||||||
if (i >= readings.length) {
|
if (i >= readings.length) {
|
||||||
throw new AssertionError("nowMillis read more times than this fixture expects (" + i
|
throw new AssertionError("nowMillis read more times than this fixture expects (" + i
|
||||||
+ "); the readiness-not-ready branch should read the clock exactly twice — "
|
+ "); the readiness-not-ready branch should read the clock exactly three times —"
|
||||||
+ "once to stamp the first non-ready sample, once at grace expiry");
|
+ " 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];
|
return readings[i];
|
||||||
};
|
};
|
||||||
@@ -603,6 +615,46 @@ class InjectorTest {
|
|||||||
assertEquals(List.of("task"), sent(), "a worker that connects within the grace is delivered to");
|
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
|
@Test
|
||||||
void dropClearsWorkerPresence() {
|
void dropClearsWorkerPresence() {
|
||||||
// CB-114 (finding #1): a vanished worker's readiness must be forgotten so a stale entry cannot
|
// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
package dev.ltms.fleet.lead;
|
package dev.ltms.fleet.lead;
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.Level;
|
||||||
|
import ch.qos.logback.classic.Logger;
|
||||||
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||||
|
import ch.qos.logback.core.read.ListAppender;
|
||||||
import dev.ltms.fleet.config.FleetConfig;
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
import dev.ltms.fleet.herdr.FakeHerdr;
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
|
import dev.ltms.fleet.herdr.ResilientAgentLaunch;
|
||||||
import dev.ltms.fleet.herdr.WorkspaceControl;
|
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -64,6 +70,21 @@ class LeadLauncherTest {
|
|||||||
return new LeadLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), cfg);
|
return new LeadLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** As {@link #launcher}, plus a fast no-op sleeper so a busy-retry test never real-sleeps. */
|
||||||
|
private static LeadLauncher fastLauncher(FakeHerdr herdr, FleetConfig cfg) {
|
||||||
|
return new LeadLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), cfg, () -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #opusProfile()} with one argv element long enough to overflow the pane line limit. */
|
||||||
|
private static FleetConfig.Profile hugeArgvProfile() {
|
||||||
|
return new FleetConfig.Profile(
|
||||||
|
"opus", null, "claude-opus-5", null, "FLEETD_WORKER_TOKEN",
|
||||||
|
List.of("ccs", "x".repeat(1500)), "tab", "fleet", null,
|
||||||
|
"http://127.0.0.1:8765/mcp", null, null,
|
||||||
|
null, null, null,
|
||||||
|
Map.of("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "300000"), null, null, true, null);
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static List<String> startedArgs(FakeHerdr herdr) {
|
private static List<String> startedArgs(FakeHerdr herdr) {
|
||||||
return (List<String>) ((Map<String, Object>) herdr.lastCall("agent.start").params()).get("args");
|
return (List<String>) ((Map<String, Object>) herdr.lastCall("agent.start").params()).get("args");
|
||||||
@@ -86,16 +107,20 @@ class LeadLauncherTest {
|
|||||||
|
|
||||||
assertEquals(1, launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads());
|
assertEquals(1, launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads());
|
||||||
assertTrue(herdr.called("agent.start"), "a lead must actually be started");
|
assertTrue(herdr.called("agent.start"), "a lead must actually be started");
|
||||||
assertEquals("lead-opus", startedName(herdr));
|
// fleetd #727: the name carries a per-process nonce and a per-start sequence number — the
|
||||||
|
// same unique-naming scheme HerdrPeerLauncher uses for members — rather than the fixed
|
||||||
|
// "lead-opus" a stale registry entry could block a legitimate relaunch under.
|
||||||
|
assertTrue(startedName(herdr).matches("lead-opus-[0-9a-f]{6}-\\d+"),
|
||||||
|
"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
|
@Test
|
||||||
void labelsTheTabWithTheConfiguredTabValue() {
|
void labelsTheTabWithTheFixedLeadTabLabel() {
|
||||||
FakeHerdr herdr = new FakeHerdr();
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads();
|
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads();
|
||||||
|
|
||||||
assertEquals("lead: opus",
|
assertEquals("lead",
|
||||||
((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
|
((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +148,25 @@ class LeadLauncherTest {
|
|||||||
assertFalse(herdr.called("tab.close"), "a labelled tab WITH a live agent must never be closed");
|
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
|
* 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.
|
* since died must not block the relaunch, or one crash disables auto-launch permanently.
|
||||||
@@ -238,7 +282,7 @@ class LeadLauncherTest {
|
|||||||
assertFalse(herdr.called("tab.close"), "a tab running an agent again must never be closed");
|
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");
|
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("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");
|
"the pending-close flag must be cleared once the tab is confirmed live again");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +304,7 @@ class LeadLauncherTest {
|
|||||||
@Test
|
@Test
|
||||||
void aHandOpenedLeadWithTheConfiguredTabLabelCountsAsLive() {
|
void aHandOpenedLeadWithTheConfiguredTabLabelCountsAsLive() {
|
||||||
FakeHerdr herdr = new FakeHerdr()
|
FakeHerdr herdr = new FakeHerdr()
|
||||||
.withWorkspace("wX", "main")
|
.withWorkspace("wX", "fleet")
|
||||||
.withTab("wX", "wX:t1", "lead: opus")
|
.withTab("wX", "wX:t1", "lead: opus")
|
||||||
.withAgent("hand-opened", "term_hand", "wX:p1", "wX:t1");
|
.withAgent("hand-opened", "term_hand", "wX:p1", "wX:t1");
|
||||||
|
|
||||||
@@ -436,4 +480,247 @@ class LeadLauncherTest {
|
|||||||
assertEquals(0, launcher(herdr, cfg).ensureLeads());
|
assertEquals(0, launcher(herdr, cfg).ensureLeads());
|
||||||
assertTrue(herdr.calls.isEmpty(), "nothing declared ⇒ nothing scanned");
|
assertTrue(herdr.calls.isEmpty(), "nothing declared ⇒ nothing scanned");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #727: the same three launch protections every member spawn gets ───────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A freshly created pane may not have redrawn its prompt yet, so herdr answers
|
||||||
|
* {@code agent_pane_busy}. The lead launch must wait it out rather than fail on the first miss
|
||||||
|
* — exactly the retry {@code HerdrPeerLauncher} already gives every member.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aLeadLaunchRetriesWhileTheSeedShellBoots() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().agentPaneBusyTimes(2);
|
||||||
|
|
||||||
|
assertEquals(1, fastLauncher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads(),
|
||||||
|
"the lead must still start once the shell is ready");
|
||||||
|
assertEquals(3, herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
|
||||||
|
"two busy rejections, then the successful start");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The busy retry is bounded, not an infinite poll. If the pane never becomes ready the launch
|
||||||
|
* must eventually give up and log the failure, not hang the daemon's reconcile loop forever —
|
||||||
|
* proven here by a budget that would still be busy on attempt
|
||||||
|
* {@value ResilientAgentLaunch#SHELL_READY_RETRIES} and a call count that stops exactly there.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aLeadLaunchGivesUpAfterTheBoundedBusyBudgetRatherThanLoopingForever() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().agentPaneBusyTimes(999);
|
||||||
|
|
||||||
|
assertEquals(0, fastLauncher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads(),
|
||||||
|
"a pane that never becomes ready must not be reported as a started lead");
|
||||||
|
assertEquals(ResilientAgentLaunch.SHELL_READY_RETRIES,
|
||||||
|
herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
|
||||||
|
"the retry budget is bounded: it stops after exactly SHELL_READY_RETRIES attempts");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* herdr refuses a duplicate agent {@code name} outright. A stale registry entry — a crashed
|
||||||
|
* lead session, or a name the registry has not yet released — must not permanently block a
|
||||||
|
* legitimate relaunch, so each retry attempt carries a fresh per-start name.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void aLeadLaunchRetriesUnderAFreshNameWhenTheOldNameIsStillTaken() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().agentNameTakenTimes(2);
|
||||||
|
|
||||||
|
assertEquals(1, launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads(),
|
||||||
|
"the lead must still start once a free name is found");
|
||||||
|
List<String> names = herdr.calls.stream()
|
||||||
|
.filter(c -> c.method().equals("agent.start"))
|
||||||
|
.map(c -> ((Map<String, Object>) c.params()).get("name").toString())
|
||||||
|
.toList();
|
||||||
|
assertEquals(3, names.size(), "2 rejected + 1 success");
|
||||||
|
assertEquals(3, Set.copyOf(names).size(), "each attempt must use a distinct name");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name-collision retry is bounded too. If the name is taken on every attempt, the launch
|
||||||
|
* must give up rather than keep minting new names forever — the per-start naming scheme means
|
||||||
|
* every failed attempt was refused outright by herdr (no process exists under a name herdr
|
||||||
|
* refused), so a bounded, exhausted retry never leaves a second process running: nothing is
|
||||||
|
* running at all.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aLeadLaunchNeverEndsUpWithASecondProcessWhenTheNameStaysTaken() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().agentNameTakenTimes(999);
|
||||||
|
|
||||||
|
assertEquals(0, launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads(),
|
||||||
|
"a name that is never free must not be reported as a started lead");
|
||||||
|
assertEquals(ResilientAgentLaunch.NAME_RETRIES,
|
||||||
|
herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
|
||||||
|
"the retry budget is bounded: it stops after exactly NAME_RETRIES attempts, "
|
||||||
|
+ "never racing a duplicate into existence");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #220/#727: herdr types the launch command into the pane as one line, and a pty line
|
||||||
|
* buffer holds only 1024 bytes — past that the tail is dropped with no error at all, and the
|
||||||
|
* backend exits on a mangled argument. The lead launch must refuse an over-long command outright
|
||||||
|
* rather than let it be typed and silently truncated.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void anOverlongLeadArgvIsRefusedRatherThanTypedAndTruncated() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
Logger logger = (Logger) LoggerFactory.getLogger(LeadLauncher.class);
|
||||||
|
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||||
|
appender.start();
|
||||||
|
logger.addAppender(appender);
|
||||||
|
|
||||||
|
int started;
|
||||||
|
try {
|
||||||
|
started = launcher(herdr, configWith(lead("opus", "lead: opus", 1), hugeArgvProfile()))
|
||||||
|
.ensureLeads();
|
||||||
|
} finally {
|
||||||
|
logger.detachAppender(appender);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(0, started, "an over-long command must never be reported as a started lead");
|
||||||
|
assertFalse(herdr.called("agent.start"),
|
||||||
|
"nothing may be started — a truncated command is worse than no spawn");
|
||||||
|
String warn = appender.list.stream()
|
||||||
|
.filter(e -> e.getLevel().equals(Level.WARN))
|
||||||
|
.map(ILoggingEvent::getFormattedMessage)
|
||||||
|
.filter(m -> m.contains("failed to launch"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("expected a WARN naming the launch failure: "
|
||||||
|
+ appender.list));
|
||||||
|
assertTrue(warn.contains("1024"), "names the limit: " + warn);
|
||||||
|
assertTrue(warn.contains("opus"), "names the profile: " + warn);
|
||||||
|
assertTrue(warn.contains("x".repeat(60)), "names the culprit argument: " + warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fleetd #726 unit 1: the single-lead relaunch seam ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The returned agent's {@code terminalId()}/{@code paneId()} are the ones the fake
|
||||||
|
* {@code AgentControl} actually started — not a coincidental field left over from the caller.
|
||||||
|
* {@code paneId()} echoes the exact {@code pane_id} the launch's own {@code agent.start} call
|
||||||
|
* carried (protocol 19: the agent starts into the pane it is asked to), and {@code
|
||||||
|
* terminalId()} is herdr's own generated id, which the fake always shapes as {@code
|
||||||
|
* term_new_<n>}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void relaunchReturnsTheStartedAgent() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
|
||||||
|
dev.ltms.fleet.herdr.Agent started =
|
||||||
|
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).relaunch("opus");
|
||||||
|
|
||||||
|
assertNotNull(started, "a launchable, configured lead must start");
|
||||||
|
Object startedPaneIdParam = ((Map<?, ?>) herdr.lastCall("agent.start").params()).get("pane_id");
|
||||||
|
assertEquals(startedPaneIdParam, started.paneId(),
|
||||||
|
"paneId() must be the pane the agent.start call actually targeted");
|
||||||
|
assertTrue(started.terminalId() != null && started.terminalId().startsWith("term_new_"),
|
||||||
|
"terminalId() must be herdr's own generated id: " + started.terminalId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The new tab is labelled with the fixed lead tab label, and AFTER the start. */
|
||||||
|
@Test
|
||||||
|
void relaunchLabelsTheNewTabAfterStarting() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
|
||||||
|
dev.ltms.fleet.herdr.Agent started =
|
||||||
|
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).relaunch("opus");
|
||||||
|
|
||||||
|
assertNotNull(started);
|
||||||
|
assertEquals("lead", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
|
||||||
|
|
||||||
|
int startIndex = indexOfLastCall(herdr, "agent.start");
|
||||||
|
int renameIndex = indexOfLastCall(herdr, "tab.rename");
|
||||||
|
assertTrue(renameIndex > startIndex,
|
||||||
|
"the tab must be renamed AFTER the start succeeds, not before: start=" + startIndex
|
||||||
|
+ " rename=" + renameIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOfLastCall(FakeHerdr herdr, String method) {
|
||||||
|
int idx = -1;
|
||||||
|
List<FakeHerdr.Call> calls = herdr.calls;
|
||||||
|
for (int i = 0; i < calls.size(); i++) {
|
||||||
|
if (calls.get(i).method().equals(method)) {
|
||||||
|
idx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void relaunchOfAnUnknownLeadNameReturnsNullAndStartsNothing() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
|
||||||
|
dev.ltms.fleet.herdr.Agent started =
|
||||||
|
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).relaunch("not-declared");
|
||||||
|
|
||||||
|
assertNull(started);
|
||||||
|
assertFalse(herdr.called("agent.start"));
|
||||||
|
assertFalse(herdr.called("workspace.create"));
|
||||||
|
assertFalse(herdr.called("tab.create"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void relaunchOfARecogniseOnlyLeadReturnsNullAndStartsNothing() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
|
||||||
|
dev.ltms.fleet.herdr.Agent started =
|
||||||
|
launcher(herdr, configWith(lead(null, "lead: dead", 1))).relaunch("opus");
|
||||||
|
|
||||||
|
assertNull(started);
|
||||||
|
assertFalse(herdr.called("agent.start"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void relaunchWithAnUnconfiguredProfileReturnsNullAndStartsNothing() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
|
||||||
|
dev.ltms.fleet.herdr.Agent started =
|
||||||
|
launcher(herdr, configWith(lead("nope", "lead: opus", 1))).relaunch("opus");
|
||||||
|
|
||||||
|
assertNull(started);
|
||||||
|
assertFalse(herdr.called("agent.start"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outer retry {@link LeadLauncher#relaunch(String)} owns, separate from {@code
|
||||||
|
* ResilientAgentLaunch}'s internal {@code agent_name_taken} retry: a failed attempt must not
|
||||||
|
* be the end of the whole relaunch. Each of the first two attempts exhausts {@code
|
||||||
|
* ResilientAgentLaunch.NAME_RETRIES} name attempts (every one of them rejected), so each
|
||||||
|
* attempt's own tab is created and then closed; the third attempt's first name is free.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void relaunchRetriesTheWholeAttemptAndSucceedsOnTheThird() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr()
|
||||||
|
.agentNameTakenTimes(2 * ResilientAgentLaunch.NAME_RETRIES);
|
||||||
|
|
||||||
|
dev.ltms.fleet.herdr.Agent started =
|
||||||
|
fastLauncher(herdr, configWith(lead("opus", "lead: opus", 1))).relaunch("opus");
|
||||||
|
|
||||||
|
assertNotNull(started, "the third attempt's first name is free — it must succeed");
|
||||||
|
assertEquals(3, herdr.calls.stream().filter(c -> c.method().equals("tab.create")).count(),
|
||||||
|
"one tab per attempt: three attempts");
|
||||||
|
assertEquals(2, herdr.calls.stream().filter(c -> c.method().equals("tab.close")).count(),
|
||||||
|
"the two failed attempts' tabs must be closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every attempt fails outright (a herdr error {@code ResilientAgentLaunch} does not retry at
|
||||||
|
* all) — {@link LeadLauncher#relaunch(String)} must give up after exactly {@code
|
||||||
|
* RELAUNCH_ATTEMPTS} and must not leak any of the tabs it created along the way.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void relaunchGivesUpAfterExactlyRelaunchAttemptsAndLeaksNoTab() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().agentStartFailsWith("some_other_error");
|
||||||
|
|
||||||
|
dev.ltms.fleet.herdr.Agent started =
|
||||||
|
fastLauncher(herdr, configWith(lead("opus", "lead: opus", 1))).relaunch("opus");
|
||||||
|
|
||||||
|
assertNull(started, "every attempt failed — relaunch must give up, not hang or guess");
|
||||||
|
assertEquals(LeadLauncher.RELAUNCH_ATTEMPTS,
|
||||||
|
herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
|
||||||
|
"exactly RELAUNCH_ATTEMPTS attempts, no more, no fewer");
|
||||||
|
long tabsCreated = herdr.calls.stream().filter(c -> c.method().equals("tab.create")).count();
|
||||||
|
long tabsClosed = herdr.calls.stream().filter(c -> c.method().equals("tab.close")).count();
|
||||||
|
assertEquals(LeadLauncher.RELAUNCH_ATTEMPTS, tabsCreated);
|
||||||
|
assertEquals(tabsCreated, tabsClosed, "every tab this method created must be closed — no leaks");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import dev.ltms.fleet.metrics.Metrics;
|
|||||||
import dev.ltms.fleet.msg.InMemoryReplyInbox;
|
import dev.ltms.fleet.msg.InMemoryReplyInbox;
|
||||||
import dev.ltms.fleet.msg.MessageService;
|
import dev.ltms.fleet.msg.MessageService;
|
||||||
import dev.ltms.fleet.msg.Rendezvous;
|
import dev.ltms.fleet.msg.Rendezvous;
|
||||||
|
import dev.ltms.fleet.peer.MemberRole;
|
||||||
import dev.ltms.fleet.session.FakeWorktrees;
|
import dev.ltms.fleet.session.FakeWorktrees;
|
||||||
import dev.ltms.fleet.session.SessionManager;
|
import dev.ltms.fleet.session.SessionManager;
|
||||||
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
||||||
@@ -63,6 +64,17 @@ class FleetMcpAuthzTest {
|
|||||||
|
|
||||||
/** A fully wired FleetMcp on fakes — constructing it is itself part of what is under test. */
|
/** A fully wired FleetMcp on fakes — constructing it is itself part of what is under test. */
|
||||||
private FleetMcp mcp(boolean enforce) {
|
private FleetMcp mcp(boolean enforce) {
|
||||||
|
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999);
|
||||||
|
return mcp(enforce, CallerResolver.withLeadsAndMembers(identity, false, null,
|
||||||
|
Map::of, new MemberRegistry(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #mcp(boolean)}, with an explicit {@link CallerResolver} — so a test can wire known
|
||||||
|
* leads/collaborators and drive {@code denyFor}'s real {@code knownLeadOrCollaborator()}
|
||||||
|
* classifier instead of the default empty one.
|
||||||
|
*/
|
||||||
|
private FleetMcp mcp(boolean enforce, CallerResolver callers) {
|
||||||
FleetConfig.Profile cfg = new FleetConfig.Profile(
|
FleetConfig.Profile cfg = new FleetConfig.Profile(
|
||||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null,
|
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null,
|
||||||
"tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
|
"tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
|
||||||
@@ -83,9 +95,7 @@ class FleetMcpAuthzTest {
|
|||||||
// to be omitted to reach "legacy" is now always real, and AuthorizationMode is the
|
// to be omitted to reach "legacy" is now always real, and AuthorizationMode is the
|
||||||
// separate, explicit choice that governs enforcement.
|
// separate, explicit choice that governs enforcement.
|
||||||
mcp = new FleetMcp(messages, workers, sessions, identity, sessions.asPresence(),
|
mcp = new FleetMcp(messages, workers, sessions, identity, sessions.asPresence(),
|
||||||
new PrimaryRegistry(null),
|
new PrimaryRegistry(null), callers,
|
||||||
CallerResolver.withLeadsAndMembers(identity, false, null,
|
|
||||||
Map::of, new MemberRegistry(null)),
|
|
||||||
enforce ? FleetMcp.AuthorizationMode.ENFORCED : FleetMcp.AuthorizationMode.UNENFORCED,
|
enforce ? FleetMcp.AuthorizationMode.ENFORCED : FleetMcp.AuthorizationMode.UNENFORCED,
|
||||||
metrics, FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
metrics, FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), null, FleetMcp.OutageSource.none(),
|
FleetMcp.QuarantineSource.none(), null, FleetMcp.OutageSource.none(),
|
||||||
@@ -97,6 +107,7 @@ class FleetMcpAuthzTest {
|
|||||||
private static final Principal WORKER_A = Principal.worker("term_a", 200);
|
private static final Principal WORKER_A = Principal.worker("term_a", 200);
|
||||||
private static final Principal ANON = Principal.anonymous();
|
private static final Principal ANON = Principal.anonymous();
|
||||||
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
|
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
|
||||||
|
private static final Principal COLLABORATOR = Principal.collaborator("ops", "term_collab", 600);
|
||||||
|
|
||||||
// --- the table, enforced on THIS path too ---------------------------------------------------
|
// --- the table, enforced on THIS path too ---------------------------------------------------
|
||||||
|
|
||||||
@@ -156,6 +167,45 @@ class FleetMcpAuthzTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit A: {@code SEND} is split into three actions ({@link Authz.Action#SEND},
|
||||||
|
* {@link Authz.Action#COORD_SEND}, {@link Authz.Action#ANSWER}), each carrying the same grant
|
||||||
|
* the one undivided action gave. An architect holds all three, exactly as it held the one.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void anArchitectMayUseAllThreeSendShapesOverMcp() {
|
||||||
|
FleetMcp m = mcp(true);
|
||||||
|
for (Authz.Action a : new Authz.Action[]{Authz.Action.SEND, Authz.Action.COORD_SEND,
|
||||||
|
Authz.Action.ANSWER}) {
|
||||||
|
assertNull(m.denyFor(ARCH_DESIGN, a, "term_a"),
|
||||||
|
a + " carries the same grant the undivided SEND action gave an architect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The other half of the same split: a worker is excluded from all three, as it was from one. */
|
||||||
|
@Test
|
||||||
|
void aWorkerMayNotUseAnySendShapeOverMcp() {
|
||||||
|
FleetMcp m = mcp(true);
|
||||||
|
for (Authz.Action a : new Authz.Action[]{Authz.Action.SEND, Authz.Action.COORD_SEND,
|
||||||
|
Authz.Action.ANSWER}) {
|
||||||
|
McpSchema.CallToolResult denied = m.denyFor(WORKER_A, a, "term_a");
|
||||||
|
assertNotNull(denied, a + " must stay refused to a worker");
|
||||||
|
assertTrue(denied.isError(), "a refusal is returned as an MCP tool error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit A / #678: {@code TASK_READ} (ticket polling, session status) is split out of
|
||||||
|
* the roster-only {@code READ}, carrying forward the grant the undivided action gave. A worker
|
||||||
|
* still has both — it never gained or lost anything by the split.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aWorkerKeepsBothReadActionsAfterTheSplit() {
|
||||||
|
FleetMcp m = mcp(true);
|
||||||
|
assertNull(m.denyFor(WORKER_A, Authz.Action.READ, null));
|
||||||
|
assertNull(m.denyFor(WORKER_A, Authz.Action.TASK_READ, null));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anArchitectMayReplyAndAskOnlyAsItsOwnPaneOverMcp() {
|
void anArchitectMayReplyAndAskOnlyAsItsOwnPaneOverMcp() {
|
||||||
FleetMcp m = mcp(true);
|
FleetMcp m = mcp(true);
|
||||||
@@ -187,6 +237,115 @@ class FleetMcpAuthzTest {
|
|||||||
"the caller IS authenticated — it is just not the right role");
|
"the caller IS authenticated — it is just not the right role");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code denyFor} passes the real production classifier, not a test-supplied one — no
|
||||||
|
* terminal is recognised as a configured lead or collaborator, so a collaborator's SEND is
|
||||||
|
* refused over MCP.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMayNotSendOverMcpWithTheRealProductionClassifier() {
|
||||||
|
FleetMcp m = mcp(true);
|
||||||
|
McpSchema.CallToolResult denied = m.denyFor(COLLABORATOR, Authz.Action.SEND, "term_lead");
|
||||||
|
assertNotNull(denied, "no terminal is recognised as a lead or collaborator yet");
|
||||||
|
assertTrue(denied.isError());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit D: wires a real {@link CallerResolver} with a known lead and a known
|
||||||
|
* collaborator tab, and leaves a spawned member's own terminal recognised by neither map — so a
|
||||||
|
* collaborator's SEND reaches both named peers and is refused for the spawned member's terminal,
|
||||||
|
* over MCP's {@code denyFor}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMaySendToAKnownLeadOrCollaboratorButNotToASpawnedMembersTerminal() {
|
||||||
|
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("term_collab_known", "ops2"));
|
||||||
|
FleetMcp m = mcp(true, callers);
|
||||||
|
|
||||||
|
assertNull(m.denyFor(COLLABORATOR, Authz.Action.SEND, "term_lead_known"));
|
||||||
|
assertNull(m.denyFor(COLLABORATOR, Authz.Action.SEND, "term_collab_known"));
|
||||||
|
assertNotNull(m.denyFor(COLLABORATOR, Authz.Action.SEND, "term_a"),
|
||||||
|
"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
|
@Test
|
||||||
void theLegacyConstructorLeavesTheGateOpen() {
|
void theLegacyConstructorLeavesTheGateOpen() {
|
||||||
// The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced.
|
// The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced.
|
||||||
@@ -273,10 +432,10 @@ class FleetMcpAuthzTest {
|
|||||||
+ "the anchors have drifted, this test is not testing what it claims to");
|
+ "the anchors have drifted, this test is not testing what it claims to");
|
||||||
|
|
||||||
Pattern trailingArg = Pattern.compile(
|
Pattern trailingArg = Pattern.compile(
|
||||||
"listFleet\\([^;]*?,\\s*(coordinatorVisibleTo\\(principal\\(exchange\\)\\)|true|false)\\s*\\)\\s*;",
|
"listFleet\\([^;]*?,\\s*(coordinatorVisibleTo\\(principal\\(exchange\\)\\)|true|false)\\s*[,)]",
|
||||||
Pattern.DOTALL);
|
Pattern.DOTALL);
|
||||||
Matcher m = trailingArg.matcher(handlerBlock);
|
Matcher m = trailingArg.matcher(handlerBlock);
|
||||||
assertTrue(m.find(), "could not locate listFleet(...)'s trailing boolean argument in the "
|
assertTrue(m.find(), "could not locate listFleet(...)'s coordinator boolean argument in the "
|
||||||
+ "listHandler block -- the call shape changed, update this test's anchor: " + handlerBlock);
|
+ "listHandler block -- the call shape changed, update this test's anchor: " + handlerBlock);
|
||||||
String trailing = m.group(1);
|
String trailing = m.group(1);
|
||||||
assertEquals("coordinatorVisibleTo(principal(exchange))", trailing,
|
assertEquals("coordinatorVisibleTo(principal(exchange))", trailing,
|
||||||
@@ -284,6 +443,223 @@ class FleetMcpAuthzTest {
|
|||||||
+ "calling, not pass a literal boolean -- found: " + trailing);
|
+ "calling, not pass a literal boolean -- found: " + trailing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fleetd #703: who may see fleet_list's collaborators array -------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #703: {@link FleetMcp#collaboratorsVisibleTo} is the whole policy decision for
|
||||||
|
* {@code fleet_list}'s {@code collaborators} array. Visible to exactly the roles that may
|
||||||
|
* {@code SEND} to a named peer -- the primary, an architect, and a collaborator itself -- never
|
||||||
|
* a worker, which holds {@code READ} but can never {@code SEND} to a collaborator, and never an
|
||||||
|
* anonymous caller.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void onlyPrimaryArchitectAndCollaboratorMaySeeTheCollaboratorsArray() {
|
||||||
|
assertTrue(FleetMcp.collaboratorsVisibleTo(PRIMARY), "the primary must see the collaborators array");
|
||||||
|
assertTrue(FleetMcp.collaboratorsVisibleTo(ARCH_DESIGN), "an architect must see the collaborators array");
|
||||||
|
assertTrue(FleetMcp.collaboratorsVisibleTo(COLLABORATOR), "a collaborator must see its own peer roster");
|
||||||
|
assertFalse(FleetMcp.collaboratorsVisibleTo(WORKER_A),
|
||||||
|
"a worker holds READ but can never SEND to a collaborator, so it must not see the array");
|
||||||
|
assertFalse(FleetMcp.collaboratorsVisibleTo(ANON), "authenticated as nothing must not see it either");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #703, same reasoning as {@link #theFleetListHandlerActuallyConsultsCoordinatorVisibleTo}:
|
||||||
|
* the predicate above can be perfectly correct while the one production call site never asks it.
|
||||||
|
* This reads {@code FleetMcp.java}'s own source and asserts the {@code fleet_list} handler's
|
||||||
|
* {@code listFleet(...)} call both threads {@code callers.collaborators()} into the payload and
|
||||||
|
* asks {@code collaboratorsVisibleTo(principal(exchange))} for the visibility flag, rather than a
|
||||||
|
* literal boolean or an empty map.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theFleetListHandlerActuallyConsultsCollaboratorsVisibleTo() throws Exception {
|
||||||
|
String source = Files.readString(MCP_SOURCE);
|
||||||
|
|
||||||
|
int start = source.indexOf("listHandler =");
|
||||||
|
assertTrue(start >= 0, "could not find the fleet_list handler (listHandler) in " + MCP_SOURCE
|
||||||
|
+ " -- the scrape has stopped matching, fix the anchor before trusting this test");
|
||||||
|
int end = source.indexOf("stopHandler =", start);
|
||||||
|
assertTrue(end > start, "could not find the handler declared after listHandler to bound the scrape");
|
||||||
|
String handlerBlock = source.substring(start, end);
|
||||||
|
|
||||||
|
// CONTROL: the block we scraped really does contain a call to listFleet(...) -- if this
|
||||||
|
// fails, the anchors above moved and the assertions below would otherwise pass on nothing.
|
||||||
|
assertTrue(handlerBlock.contains("listFleet("),
|
||||||
|
"control failed: the scraped listHandler block contains no listFleet( call at all -- "
|
||||||
|
+ "the anchors have drifted, this test is not testing what it claims to");
|
||||||
|
|
||||||
|
assertTrue(handlerBlock.contains("callers.collaborators()"),
|
||||||
|
"the fleet_list handler must thread callers.collaborators() into listFleet(...), not an "
|
||||||
|
+ "empty or literal map -- block: " + handlerBlock);
|
||||||
|
assertTrue(handlerBlock.contains("collaboratorsVisibleTo(principal(exchange))"),
|
||||||
|
"the fleet_list handler must ask collaboratorsVisibleTo(principal(exchange)) who is "
|
||||||
|
+ "calling, not pass a literal boolean -- block: " + handlerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- who may see fleet_list's leads and members arrays ---------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link FleetMcp#leadsVisibleTo} is the whole policy decision for {@code fleet_list}'s
|
||||||
|
* {@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() {
|
||||||
|
assertTrue(FleetMcp.leadsVisibleTo(PRIMARY), "the primary must see the leads array");
|
||||||
|
assertTrue(FleetMcp.leadsVisibleTo(ARCH_DESIGN), "an architect must see the leads array");
|
||||||
|
assertTrue(FleetMcp.leadsVisibleTo(COLLABORATOR),
|
||||||
|
"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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #primaryArchitectAndCollaboratorMaySeeTheLeadsArray}, for the {@code members}
|
||||||
|
* array -- but a collaborator may {@code SEND} only to a lead or another collaborator, never
|
||||||
|
* to a spawned member, so it must not see this one.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void onlyPrimaryAndArchitectMaySeeTheMembersArray() {
|
||||||
|
assertTrue(FleetMcp.membersVisibleTo(PRIMARY), "the primary must see the members array");
|
||||||
|
assertTrue(FleetMcp.membersVisibleTo(ARCH_DESIGN), "an architect must see the members array");
|
||||||
|
assertFalse(FleetMcp.membersVisibleTo(WORKER_A),
|
||||||
|
"a worker holds READ but can never SEND, so it must not see the members array");
|
||||||
|
assertFalse(FleetMcp.membersVisibleTo(COLLABORATOR),
|
||||||
|
"a collaborator may SEND to a lead, never to a spawned member, so it must not see the members array");
|
||||||
|
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.
|
||||||
|
* This reads {@code FleetMcp.java}'s own source and asserts the {@code fleet_list} handler's
|
||||||
|
* {@code listFleet(...)} call asks {@code leadsVisibleTo(principal(exchange))} for the leads
|
||||||
|
* visibility flag, rather than a literal boolean.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theFleetListHandlerActuallyConsultsLeadsVisibleTo() throws Exception {
|
||||||
|
String source = Files.readString(MCP_SOURCE);
|
||||||
|
|
||||||
|
int start = source.indexOf("listHandler =");
|
||||||
|
assertTrue(start >= 0, "could not find the fleet_list handler (listHandler) in " + MCP_SOURCE
|
||||||
|
+ " -- the scrape has stopped matching, fix the anchor before trusting this test");
|
||||||
|
int end = source.indexOf("stopHandler =", start);
|
||||||
|
assertTrue(end > start, "could not find the handler declared after listHandler to bound the scrape");
|
||||||
|
String handlerBlock = source.substring(start, end);
|
||||||
|
|
||||||
|
// CONTROL: the block we scraped really does contain a call to listFleet(...) -- if this
|
||||||
|
// fails, the anchors above moved and the assertion below would otherwise pass on nothing.
|
||||||
|
assertTrue(handlerBlock.contains("listFleet("),
|
||||||
|
"control failed: the scraped listHandler block contains no listFleet( call at all -- "
|
||||||
|
+ "the anchors have drifted, this test is not testing what it claims to");
|
||||||
|
|
||||||
|
assertTrue(handlerBlock.contains("leadsVisibleTo(principal(exchange))"),
|
||||||
|
"the fleet_list handler must ask leadsVisibleTo(principal(exchange)) who is calling, "
|
||||||
|
+ "not pass a literal boolean -- block: " + handlerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As {@link #theFleetListHandlerActuallyConsultsLeadsVisibleTo}, for {@code membersVisibleTo}. */
|
||||||
|
@Test
|
||||||
|
void theFleetListHandlerActuallyConsultsMembersVisibleTo() throws Exception {
|
||||||
|
String source = Files.readString(MCP_SOURCE);
|
||||||
|
|
||||||
|
int start = source.indexOf("listHandler =");
|
||||||
|
assertTrue(start >= 0, "could not find the fleet_list handler (listHandler) in " + MCP_SOURCE
|
||||||
|
+ " -- the scrape has stopped matching, fix the anchor before trusting this test");
|
||||||
|
int end = source.indexOf("stopHandler =", start);
|
||||||
|
assertTrue(end > start, "could not find the handler declared after listHandler to bound the scrape");
|
||||||
|
String handlerBlock = source.substring(start, end);
|
||||||
|
|
||||||
|
assertTrue(handlerBlock.contains("listFleet("),
|
||||||
|
"control failed: the scraped listHandler block contains no listFleet( call at all -- "
|
||||||
|
+ "the anchors have drifted, this test is not testing what it claims to");
|
||||||
|
|
||||||
|
assertTrue(handlerBlock.contains("membersVisibleTo(principal(exchange))"),
|
||||||
|
"the fleet_list handler must ask membersVisibleTo(principal(exchange)) who is calling, "
|
||||||
|
+ "not pass a literal boolean -- block: " + handlerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_poll{ticket}} must thread the calling connection's owner key into
|
||||||
|
* {@link MessageService#poll(String, String)}, so a worker cannot read a ticket a different
|
||||||
|
* session created.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theFleetPollHandlerActuallyThreadsCallerOwnerIntoPoll() throws Exception {
|
||||||
|
String source = Files.readString(MCP_SOURCE);
|
||||||
|
|
||||||
|
int start = source.indexOf("pollHandler =");
|
||||||
|
assertTrue(start >= 0, "could not find the fleet_poll handler (pollHandler) in " + MCP_SOURCE
|
||||||
|
+ " -- the scrape has stopped matching, fix the anchor before trusting this test");
|
||||||
|
int end = source.indexOf("ackHandler =", start);
|
||||||
|
assertTrue(end > start, "could not find the handler declared after pollHandler to bound the scrape");
|
||||||
|
String handlerBlock = source.substring(start, end);
|
||||||
|
|
||||||
|
// CONTROL: the block we scraped really does call poll(...) -- if this fails, the anchors
|
||||||
|
// above moved and the assertions below would otherwise pass on nothing.
|
||||||
|
assertTrue(handlerBlock.contains("poll(messages,"),
|
||||||
|
"control failed: the scraped pollHandler block contains no poll(messages, ...) call "
|
||||||
|
+ "at all -- the anchors have drifted, this test is not testing what it claims to");
|
||||||
|
|
||||||
|
assertTrue(handlerBlock.contains("principal(exchange).ownerKey()"),
|
||||||
|
"the fleet_poll handler must thread principal(exchange).ownerKey() into poll(...), not omit "
|
||||||
|
+ "it or pass a literal null -- block: " + handlerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_status} must thread the calling connection's owner key into
|
||||||
|
* {@link FleetMcp#status(MessageService, String, String)}, so a caller that did not create a
|
||||||
|
* worker's open delegation cannot read its pending question through the status handler either.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theFleetStatusHandlerActuallyThreadsCallerOwnerIntoStatus() throws Exception {
|
||||||
|
String source = Files.readString(MCP_SOURCE);
|
||||||
|
|
||||||
|
int start = source.indexOf("statusHandler =");
|
||||||
|
assertTrue(start >= 0, "could not find the fleet_status handler (statusHandler) in " + MCP_SOURCE
|
||||||
|
+ " -- the scrape has stopped matching, fix the anchor before trusting this test");
|
||||||
|
int end = source.indexOf("pollHandler =", start);
|
||||||
|
assertTrue(end > start, "could not find the handler declared after statusHandler to bound the scrape");
|
||||||
|
String handlerBlock = source.substring(start, end);
|
||||||
|
|
||||||
|
// CONTROL: the block we scraped really does call status(...) -- if this fails, the anchors
|
||||||
|
// above moved and the assertions below would otherwise pass on nothing.
|
||||||
|
assertTrue(handlerBlock.contains("status(messages,"),
|
||||||
|
"control failed: the scraped statusHandler block contains no status(messages, ...) "
|
||||||
|
+ "call at all -- the anchors have drifted, this test is not testing what it claims to");
|
||||||
|
|
||||||
|
assertTrue(handlerBlock.contains("principal(exchange).ownerKey()"),
|
||||||
|
"the fleet_status handler must thread principal(exchange).ownerKey() into status(...), not "
|
||||||
|
+ "omit it or pass a literal null -- block: " + handlerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
// --- which action each tool hands the gate (fleetd #272) ------------------------------------
|
// --- which action each tool hands the gate (fleetd #272) ------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -297,35 +673,53 @@ class FleetMcpAuthzTest {
|
|||||||
* the whole time the defect was live -- the table was right, the action fed to it was wrong.
|
* the whole time the defect was live -- the table was right, the action fed to it was wrong.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void pollingByTargetIsADrainAndPollingByTicketIsARead() {
|
void pollingByTargetIsADrainAndPollingByTicketIsATaskRead() {
|
||||||
assertEquals(Authz.Action.DRAIN, FleetMcp.pollAction("term_b", null),
|
assertEquals(Authz.Action.DRAIN, FleetMcp.pollAction("term_b", null),
|
||||||
"poll by target removes the replies — that is a drain, not an observation");
|
"poll by target removes the replies — that is a drain, not an observation");
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.pollAction(null, null),
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(null, null),
|
||||||
"poll by ticket changes nothing");
|
"poll by ticket changes nothing, but is not the roster-only READ action");
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.pollAction(" ", null),
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(" ", null),
|
||||||
"a blank target is an absent target");
|
"a blank target is an absent target");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fleetd #421: a coordId branch is a THIRD operation behind fleet_poll's one name, and it must
|
* fleetd #421: a coordId branch is a THIRD operation behind fleet_poll's one name, and it must
|
||||||
* map to {@link Authz.Action#COORD_READ} — never {@link Authz.Action#READ}, even though this
|
* map to {@link Authz.Action#COORD_READ} — never {@link Authz.Action#READ} or {@link
|
||||||
* branch also consumes nothing. READ's grant is open to every authenticated role on the premise
|
* Authz.Action#TASK_READ}, even though this branch also consumes nothing. A lead-to-lead body
|
||||||
* that the roster carries no secrets; a lead-to-lead body is not the roster, so folding this
|
* is not the roster and not a ticket/status read, so folding this branch into either would let
|
||||||
* branch into READ would let any worker read every peer lead's mail in full. coordId also takes
|
* any worker or architect read every peer lead's mail in full. coordId also takes priority over
|
||||||
* priority over target when both happen to be present — it addresses a different inbox entirely.
|
* target when both happen to be present — it addresses a different inbox entirely.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void pollingByCoordIdIsACoordReadNeverAPlainRead() {
|
void pollingByCoordIdIsACoordReadNeverAPlainOrTaskRead() {
|
||||||
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(null, "mac-opus"),
|
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(null, "mac-opus"),
|
||||||
"reading held peer mail must not be mapped to the everyone-readable READ action");
|
"reading held peer mail must not be mapped to a widely-readable action");
|
||||||
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(" ", "mac-opus"),
|
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(" ", "mac-opus"),
|
||||||
"a blank target must not fall through to READ/DRAIN when coordId is present");
|
"a blank target must not fall through to READ/DRAIN when coordId is present");
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.pollAction(null, " "),
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(null, " "),
|
||||||
"a blank coordId is an absent coordId, same as target/ticket");
|
"a blank coordId is an absent coordId, same as target/ticket");
|
||||||
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction("term_b", "mac-opus"),
|
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction("term_b", "mac-opus"),
|
||||||
"coordId takes priority over target — this is a different inbox, not a drain");
|
"coordId takes priority over target — this is a different inbox, not a drain");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_send} is three call shapes behind one tool name, exactly as {@code fleet_poll}
|
||||||
|
* is (fleetd #669 Unit A). {@link FleetMcp#sendAction} picks the action from the arguments, not
|
||||||
|
* the handler, for the same reason {@link FleetMcp#pollAction} does: a test can assert the
|
||||||
|
* mapping the handler actually uses.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void sendMapsToThreeDifferentActionsByItsArguments() {
|
||||||
|
assertEquals(Authz.Action.SEND, FleetMcp.sendAction(null, null),
|
||||||
|
"a plain delivery, with neither coordId nor turnId, is a local SEND");
|
||||||
|
assertEquals(Authz.Action.COORD_SEND, FleetMcp.sendAction("mac-opus", null),
|
||||||
|
"coordId addresses a peer lead over the coordination broker");
|
||||||
|
assertEquals(Authz.Action.ANSWER, FleetMcp.sendAction(null, "turn-1"),
|
||||||
|
"turnId resolves a worker's blocked question");
|
||||||
|
assertEquals(Authz.Action.COORD_SEND, FleetMcp.sendAction("mac-opus", "turn-1"),
|
||||||
|
"coordId takes priority over turnId, mirroring sendToLead's own mutual-exclusion check");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everyRegisteredToolHasItsHandlerActionPinned() {
|
void everyRegisteredToolHasItsHandlerActionPinned() {
|
||||||
// fleetd #469: this used to scrape FleetMcp.java's tool("…") calls for the registered set —
|
// fleetd #469: this used to scrape FleetMcp.java's tool("…") calls for the registered set —
|
||||||
@@ -344,16 +738,22 @@ class FleetMcpAuthzTest {
|
|||||||
() -> tool + " is registered but has no pinned authorization action"));
|
() -> tool + " is registered but has no pinned authorization action"));
|
||||||
|
|
||||||
assertEquals(Authz.Action.SEND, FleetMcp.toolAction("fleet_send", Map.of()));
|
assertEquals(Authz.Action.SEND, FleetMcp.toolAction("fleet_send", Map.of()));
|
||||||
|
assertEquals(Authz.Action.SEND,
|
||||||
|
FleetMcp.toolAction("fleet_send", Map.of("sessionId", "term_a", "content", "hi")));
|
||||||
|
assertEquals(Authz.Action.COORD_SEND,
|
||||||
|
FleetMcp.toolAction("fleet_send", Map.of("coordId", "mac-opus", "content", "hi")));
|
||||||
|
assertEquals(Authz.Action.ANSWER,
|
||||||
|
FleetMcp.toolAction("fleet_send", Map.of("turnId", "turn-1", "content", "hi")));
|
||||||
assertEquals(Authz.Action.REPLY, FleetMcp.toolAction("fleet_reply", Map.of()));
|
assertEquals(Authz.Action.REPLY, FleetMcp.toolAction("fleet_reply", Map.of()));
|
||||||
assertEquals(Authz.Action.ASK, FleetMcp.toolAction("fleet_ask", Map.of()));
|
assertEquals(Authz.Action.ASK, FleetMcp.toolAction("fleet_ask", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_status", Map.of()));
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.toolAction("fleet_status", Map.of()));
|
||||||
assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_ack", Map.of()));
|
assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_ack", Map.of()));
|
||||||
assertEquals(Authz.Action.SPAWN, FleetMcp.toolAction("fleet_spawn", Map.of()));
|
assertEquals(Authz.Action.SPAWN, FleetMcp.toolAction("fleet_spawn", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_list", Map.of()));
|
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_list", Map.of()));
|
||||||
assertEquals(Authz.Action.STOP, FleetMcp.toolAction("fleet_stop", Map.of()));
|
assertEquals(Authz.Action.STOP, FleetMcp.toolAction("fleet_stop", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_profiles", Map.of()));
|
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_profiles", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_whoami", Map.of()));
|
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_whoami", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_poll", Map.of("ticket", "task")));
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.toolAction("fleet_poll", Map.of("ticket", "task")));
|
||||||
assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_poll", Map.of("target", "term_b")));
|
assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_poll", Map.of("target", "term_b")));
|
||||||
assertEquals(Authz.Action.COORD_READ,
|
assertEquals(Authz.Action.COORD_READ,
|
||||||
FleetMcp.toolAction("fleet_poll", Map.of("coordId", "mac-opus")));
|
FleetMcp.toolAction("fleet_poll", Map.of("coordId", "mac-opus")));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import dev.ltms.fleet.herdr.FakeHerdr;
|
|||||||
import dev.ltms.fleet.herdr.PaneLocator;
|
import dev.ltms.fleet.herdr.PaneLocator;
|
||||||
import dev.ltms.fleet.herdr.WorkspaceControl;
|
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||||
import dev.ltms.fleet.inject.Injector;
|
import dev.ltms.fleet.inject.Injector;
|
||||||
|
import dev.ltms.fleet.lead.LeadLauncher;
|
||||||
import dev.ltms.fleet.lead.LeadRollover;
|
import dev.ltms.fleet.lead.LeadRollover;
|
||||||
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
||||||
import dev.ltms.fleet.msg.InMemoryReplyInbox;
|
import dev.ltms.fleet.msg.InMemoryReplyInbox;
|
||||||
@@ -24,6 +25,8 @@ import org.junit.jupiter.api.DisplayName;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -36,14 +39,14 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
* fleetd #480 Unit C — the {@code fleet_handover} MCP tool, the surface that finally calls
|
* fleetd #480 Unit C — the {@code fleet_handover} MCP tool, the surface that finally calls
|
||||||
* {@link LeadRollover#open}/{@link LeadRollover#confirm}/{@link LeadRollover#cancel}.
|
* {@link LeadRollover#open}/{@link LeadRollover#confirm}/{@link LeadRollover#cancel}.
|
||||||
*
|
*
|
||||||
* <p>Uses {@link LeadRollover}'s PUBLIC constructor (real wall clock, real 250ms settle poll, a
|
* <p>Uses {@link LeadRollover}'s PUBLIC constructor (real wall clock, real 250ms poll, a real
|
||||||
* real virtual-thread continuation runner) rather than its package-private test constructor —
|
* virtual-thread continuation runner) rather than its package-private test constructor — this
|
||||||
* this test lives in {@code dev.ltms.fleet.mcp}, not {@code dev.ltms.fleet.lead}, and does not
|
* test lives in {@code dev.ltms.fleet.mcp}, not {@code dev.ltms.fleet.lead}, and does not need to
|
||||||
* need to control the post-{@code confirm()} continuation's timing: it only asserts the
|
* control the post-{@code confirm()} continuation's timing: it only asserts the SYNCHRONOUS return
|
||||||
* SYNCHRONOUS return value of {@code open}/{@code confirm}/{@code cancel}, which is exactly what
|
* value of {@code open}/{@code confirm}/{@code cancel}, which is exactly what {@code
|
||||||
* {@code FleetMcp.handover} forwards to the client. {@code turnSettleSeconds}/{@code
|
* FleetMcp.handover} forwards to the client. {@code turnSettleSeconds}/{@code
|
||||||
* clearSettleSeconds} are kept at 1s so a confirmed request's background continuation (which this
|
* relaunchReadySeconds} are kept at 1s so a confirmed request's background continuation (which
|
||||||
* class does not wait on or assert against) gives up quickly rather than polling for 20s on a
|
* this class does not wait on or assert against) gives up quickly rather than polling for 20s on a
|
||||||
* daemon virtual thread.
|
* daemon virtual thread.
|
||||||
*/
|
*/
|
||||||
class FleetMcpHandoverTest {
|
class FleetMcpHandoverTest {
|
||||||
@@ -53,9 +56,14 @@ class FleetMcpHandoverTest {
|
|||||||
|
|
||||||
private static final String LEAD = "term_lead";
|
private static final String LEAD = "term_lead";
|
||||||
private static final String OTHER_LEAD = "term_other_lead";
|
private static final String OTHER_LEAD = "term_other_lead";
|
||||||
|
private static final String LEAD_OWNER = "leader:lead";
|
||||||
|
|
||||||
private final FakeHerdr herdr = new FakeHerdr();
|
private final FakeHerdr herdr = new FakeHerdr();
|
||||||
private final AgentControl agents = new AgentControl(herdr);
|
private final AgentControl agents = new AgentControl(herdr);
|
||||||
|
/** Fed to every direct {@code FleetMcp.handover} call below — none of this class's own tests
|
||||||
|
* exercise ticket/ask ownership, so a single instance with no delegations is enough. */
|
||||||
|
private final MessageService messages = new MessageService(agents, new Injector(agents),
|
||||||
|
new Rendezvous(), new InMemoryReplyInbox());
|
||||||
private FleetMcp mcp;
|
private FleetMcp mcp;
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
@@ -67,10 +75,26 @@ class FleetMcpHandoverTest {
|
|||||||
return new FleetConfig.LeadRollover(handoverPath, false, 3600, 1, 1, "read the handover file");
|
return new FleetConfig.LeadRollover(handoverPath, false, 3600, 1, 1, "read the handover file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static FleetConfig minimalFleetConfig() {
|
||||||
|
try {
|
||||||
|
Path yaml = Files.createTempFile("fleet-mcp-handover-test", ".yaml");
|
||||||
|
Files.writeString(yaml, "bind:\n port: 8080\n");
|
||||||
|
return FleetConfig.load(yaml);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private LeadRollover newRollover(String handoverPath) {
|
private LeadRollover newRollover(String handoverPath) {
|
||||||
// Every handoverPath this test class uses comes from tmp.resolve(...), which is already
|
// Every handoverPath this test class uses comes from tmp.resolve(...), which is already
|
||||||
// absolute, so the workspace lookup is never actually consulted — a no-op lookup is enough.
|
// absolute, so the workspace lookup is never actually consulted — a no-op lookup is enough.
|
||||||
return new LeadRollover(agents, () -> cfg(handoverPath), _ -> null);
|
// None of this class's tests reach the recognition-wait or the relaunch call, so the
|
||||||
|
// launcher's own functional correctness is irrelevant here — any constructed instance,
|
||||||
|
// backed by the same fake herdr, is enough.
|
||||||
|
WorkspaceControl spaces = new WorkspaceControl(herdr);
|
||||||
|
LeadLauncher launcher = new LeadLauncher(agents, spaces, minimalFleetConfig());
|
||||||
|
return new LeadRollover(agents, spaces, launcher, () -> cfg(handoverPath),
|
||||||
|
_ -> null, _ -> null, Map::of);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A fully wired FleetMcp on fakes (mirrors FleetMcpAuthzTest's helper), plus a leadRollover. */
|
/** A fully wired FleetMcp on fakes (mirrors FleetMcpAuthzTest's helper), plus a leadRollover. */
|
||||||
@@ -132,16 +156,16 @@ class FleetMcpHandoverTest {
|
|||||||
@DisplayName("with leadRollover: absent, every action returns a clean NOT_CONFIGURED refusal and never throws")
|
@DisplayName("with leadRollover: absent, every action returns a clean NOT_CONFIGURED refusal and never throws")
|
||||||
void nullLeadRolloverRefusesCleanlyForEveryAction() {
|
void nullLeadRolloverRefusesCleanlyForEveryAction() {
|
||||||
McpSchema.CallToolResult open = assertDoesNotThrow(
|
McpSchema.CallToolResult open = assertDoesNotThrow(
|
||||||
() -> FleetMcp.handover(null, LEAD, Map.of("action", "open")));
|
() -> FleetMcp.handover(null, messages, LEAD, LEAD_OWNER, Map.of("action", "open")));
|
||||||
assertFalse(open.isError(), "a refusal is not a protocol error: " + textOf(open));
|
assertFalse(open.isError(), "a refusal is not a protocol error: " + textOf(open));
|
||||||
assertTrue(textOf(open).contains("NOT_CONFIGURED"), textOf(open));
|
assertTrue(textOf(open).contains("NOT_CONFIGURED"), textOf(open));
|
||||||
|
|
||||||
McpSchema.CallToolResult confirm = assertDoesNotThrow(() -> FleetMcp.handover(null, LEAD,
|
McpSchema.CallToolResult confirm = assertDoesNotThrow(() -> FleetMcp.handover(null, messages, LEAD, LEAD_OWNER,
|
||||||
Map.of("action", "confirm", "token", "whatever")));
|
Map.of("action", "confirm", "token", "whatever")));
|
||||||
assertFalse(confirm.isError());
|
assertFalse(confirm.isError());
|
||||||
assertTrue(textOf(confirm).contains("NOT_CONFIGURED"), textOf(confirm));
|
assertTrue(textOf(confirm).contains("NOT_CONFIGURED"), textOf(confirm));
|
||||||
|
|
||||||
McpSchema.CallToolResult cancel = assertDoesNotThrow(() -> FleetMcp.handover(null, LEAD,
|
McpSchema.CallToolResult cancel = assertDoesNotThrow(() -> FleetMcp.handover(null, messages, LEAD, LEAD_OWNER,
|
||||||
Map.of("action", "cancel", "token", "whatever")));
|
Map.of("action", "cancel", "token", "whatever")));
|
||||||
assertFalse(cancel.isError());
|
assertFalse(cancel.isError());
|
||||||
assertTrue(textOf(cancel).contains("NOT_CONFIGURED"), textOf(cancel));
|
assertTrue(textOf(cancel).contains("NOT_CONFIGURED"), textOf(cancel));
|
||||||
@@ -150,11 +174,11 @@ class FleetMcpHandoverTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("a blank/unknown action is a clean tool error, never an exception")
|
@DisplayName("a blank/unknown action is a clean tool error, never an exception")
|
||||||
void unknownActionIsACleanError() {
|
void unknownActionIsACleanError() {
|
||||||
McpSchema.CallToolResult missing = assertDoesNotThrow(() -> FleetMcp.handover(null, LEAD, Map.of()));
|
McpSchema.CallToolResult missing = assertDoesNotThrow(() -> FleetMcp.handover(null, messages, LEAD, LEAD_OWNER, Map.of()));
|
||||||
assertTrue(missing.isError());
|
assertTrue(missing.isError());
|
||||||
|
|
||||||
McpSchema.CallToolResult bogus = assertDoesNotThrow(
|
McpSchema.CallToolResult bogus = assertDoesNotThrow(
|
||||||
() -> FleetMcp.handover(null, LEAD, Map.of("action", "bogus")));
|
() -> FleetMcp.handover(null, messages, LEAD, LEAD_OWNER, Map.of("action", "bogus")));
|
||||||
assertTrue(bogus.isError());
|
assertTrue(bogus.isError());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +234,7 @@ class FleetMcpHandoverTest {
|
|||||||
Files.writeString(handover, "not written yet");
|
Files.writeString(handover, "not written yet");
|
||||||
LeadRollover rollover = newRollover(handover.toString());
|
LeadRollover rollover = newRollover(handover.toString());
|
||||||
|
|
||||||
McpSchema.CallToolResult openResult = FleetMcp.handover(rollover, LEAD, Map.of("action", "open"));
|
McpSchema.CallToolResult openResult = FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER, Map.of("action", "open"));
|
||||||
assertFalse(openResult.isError(), textOf(openResult));
|
assertFalse(openResult.isError(), textOf(openResult));
|
||||||
String token = extractToken(textOf(openResult));
|
String token = extractToken(textOf(openResult));
|
||||||
|
|
||||||
@@ -219,13 +243,13 @@ class FleetMcpHandoverTest {
|
|||||||
Thread.sleep(50);
|
Thread.sleep(50);
|
||||||
Files.writeString(handover, "the real handover content");
|
Files.writeString(handover, "the real handover content");
|
||||||
|
|
||||||
McpSchema.CallToolResult wrongCaller = FleetMcp.handover(rollover, OTHER_LEAD,
|
McpSchema.CallToolResult wrongCaller = FleetMcp.handover(rollover, messages, OTHER_LEAD, "leader:other-lead",
|
||||||
Map.of("action", "confirm", "token", token));
|
Map.of("action", "confirm", "token", token));
|
||||||
assertFalse(wrongCaller.isError(), "a refusal is a legitimate outcome, not a protocol error");
|
assertFalse(wrongCaller.isError(), "a refusal is a legitimate outcome, not a protocol error");
|
||||||
assertTrue(textOf(wrongCaller).contains("NOT_YOUR_ROLLOVER"),
|
assertTrue(textOf(wrongCaller).contains("NOT_YOUR_ROLLOVER"),
|
||||||
"a different lead terminal confirming must surface NOT_YOUR_ROLLOVER: " + textOf(wrongCaller));
|
"a different lead terminal confirming must surface NOT_YOUR_ROLLOVER: " + textOf(wrongCaller));
|
||||||
|
|
||||||
McpSchema.CallToolResult confirmed = FleetMcp.handover(rollover, LEAD,
|
McpSchema.CallToolResult confirmed = FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER,
|
||||||
Map.of("action", "confirm", "token", token));
|
Map.of("action", "confirm", "token", token));
|
||||||
assertFalse(confirmed.isError(), textOf(confirmed));
|
assertFalse(confirmed.isError(), textOf(confirmed));
|
||||||
assertTrue(textOf(confirmed).contains("\"accepted\":true"),
|
assertTrue(textOf(confirmed).contains("\"accepted\":true"),
|
||||||
@@ -239,7 +263,7 @@ class FleetMcpHandoverTest {
|
|||||||
void cancelUnknownTokenIsCleanNotAFailure() {
|
void cancelUnknownTokenIsCleanNotAFailure() {
|
||||||
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
||||||
|
|
||||||
McpSchema.CallToolResult r = FleetMcp.handover(rollover, LEAD,
|
McpSchema.CallToolResult r = FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER,
|
||||||
Map.of("action", "cancel", "token", "does-not-exist"));
|
Map.of("action", "cancel", "token", "does-not-exist"));
|
||||||
assertFalse(r.isError());
|
assertFalse(r.isError());
|
||||||
assertTrue(textOf(r).contains("\"cancelled\":false"), textOf(r));
|
assertTrue(textOf(r).contains("\"cancelled\":false"), textOf(r));
|
||||||
@@ -250,9 +274,9 @@ class FleetMcpHandoverTest {
|
|||||||
@DisplayName("cancel on a token actually opened reports cancelled:true")
|
@DisplayName("cancel on a token actually opened reports cancelled:true")
|
||||||
void cancelKnownTokenSucceeds() {
|
void cancelKnownTokenSucceeds() {
|
||||||
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
||||||
String token = extractToken(textOf(FleetMcp.handover(rollover, LEAD, Map.of("action", "open"))));
|
String token = extractToken(textOf(FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER, Map.of("action", "open"))));
|
||||||
|
|
||||||
McpSchema.CallToolResult r = FleetMcp.handover(rollover, LEAD,
|
McpSchema.CallToolResult r = FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER,
|
||||||
Map.of("action", "cancel", "token", token));
|
Map.of("action", "cancel", "token", token));
|
||||||
assertFalse(r.isError());
|
assertFalse(r.isError());
|
||||||
assertTrue(textOf(r).contains("\"cancelled\":true"), textOf(r));
|
assertTrue(textOf(r).contains("\"cancelled\":true"), textOf(r));
|
||||||
@@ -263,7 +287,7 @@ class FleetMcpHandoverTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("status on a null LeadRollover is a clean NOT_CONFIGURED refusal, never a throw")
|
@DisplayName("status on a null LeadRollover is a clean NOT_CONFIGURED refusal, never a throw")
|
||||||
void statusWithNullLeadRolloverRefusesCleanly() {
|
void statusWithNullLeadRolloverRefusesCleanly() {
|
||||||
McpSchema.CallToolResult r = assertDoesNotThrow(() -> FleetMcp.handover(null, LEAD,
|
McpSchema.CallToolResult r = assertDoesNotThrow(() -> FleetMcp.handover(null, messages, LEAD, LEAD_OWNER,
|
||||||
Map.of("action", "status", "token", "whatever")));
|
Map.of("action", "status", "token", "whatever")));
|
||||||
assertFalse(r.isError());
|
assertFalse(r.isError());
|
||||||
assertTrue(textOf(r).contains("NOT_CONFIGURED"), textOf(r));
|
assertTrue(textOf(r).contains("NOT_CONFIGURED"), textOf(r));
|
||||||
@@ -274,7 +298,7 @@ class FleetMcpHandoverTest {
|
|||||||
void statusOnUnknownTokenReportsUnknown() {
|
void statusOnUnknownTokenReportsUnknown() {
|
||||||
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
||||||
|
|
||||||
McpSchema.CallToolResult r = FleetMcp.handover(rollover, LEAD,
|
McpSchema.CallToolResult r = FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER,
|
||||||
Map.of("action", "status", "token", "does-not-exist"));
|
Map.of("action", "status", "token", "does-not-exist"));
|
||||||
assertFalse(r.isError());
|
assertFalse(r.isError());
|
||||||
assertTrue(textOf(r).contains("\"state\":\"UNKNOWN\""), textOf(r));
|
assertTrue(textOf(r).contains("\"state\":\"UNKNOWN\""), textOf(r));
|
||||||
@@ -284,9 +308,9 @@ class FleetMcpHandoverTest {
|
|||||||
@DisplayName("status on a token that is still pending (opened, not confirmed) reports PENDING")
|
@DisplayName("status on a token that is still pending (opened, not confirmed) reports PENDING")
|
||||||
void statusOnPendingTokenReportsPending() {
|
void statusOnPendingTokenReportsPending() {
|
||||||
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
||||||
String token = extractToken(textOf(FleetMcp.handover(rollover, LEAD, Map.of("action", "open"))));
|
String token = extractToken(textOf(FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER, Map.of("action", "open"))));
|
||||||
|
|
||||||
McpSchema.CallToolResult r = FleetMcp.handover(rollover, LEAD,
|
McpSchema.CallToolResult r = FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER,
|
||||||
Map.of("action", "status", "token", token));
|
Map.of("action", "status", "token", token));
|
||||||
assertFalse(r.isError());
|
assertFalse(r.isError());
|
||||||
assertTrue(textOf(r).contains("\"state\":\"PENDING\""), textOf(r));
|
assertTrue(textOf(r).contains("\"state\":\"PENDING\""), textOf(r));
|
||||||
@@ -304,5 +328,40 @@ class FleetMcpHandoverTest {
|
|||||||
"the tool's own description must advertise the 'status' action: " + tool.description());
|
"the tool's own description must advertise the 'status' action: " + tool.description());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- unit 5: open() reports outstanding tickets and open asks ------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("open() keeps token/handoverPath/requestedAtMillis and reports empty outstanding collections when the caller has nothing")
|
||||||
|
void openReportsEmptyOutstandingCollectionsWhenCallerHasNothing() {
|
||||||
|
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
||||||
|
|
||||||
|
McpSchema.CallToolResult r = FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER,
|
||||||
|
Map.of("action", "open"));
|
||||||
|
assertFalse(r.isError(), textOf(r));
|
||||||
|
String json = textOf(r);
|
||||||
|
assertTrue(json.contains("\"token\":"), json);
|
||||||
|
assertTrue(json.contains("\"handoverPath\":"), json);
|
||||||
|
assertTrue(json.contains("\"requestedAtMillis\":"), json);
|
||||||
|
assertTrue(json.contains("\"outstandingTickets\":[]"),
|
||||||
|
"a caller with nothing gets an empty array, not an absent key: " + json);
|
||||||
|
assertTrue(json.contains("\"openAsks\":[]"),
|
||||||
|
"a caller with nothing gets an empty array, not an absent key: " + json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("open() reports an owned pending ticket with its phase and target")
|
||||||
|
void openReportsAnOwnedPendingTicketWithItsPhase() {
|
||||||
|
LeadRollover rollover = newRollover(tmp.resolve("h.md").toString());
|
||||||
|
String ticket = messages.sendAsync("term_worker", "a task", null, Principal.leader("lead", LEAD, 1));
|
||||||
|
|
||||||
|
McpSchema.CallToolResult r = FleetMcp.handover(rollover, messages, LEAD, LEAD_OWNER,
|
||||||
|
Map.of("action", "open"));
|
||||||
|
assertFalse(r.isError(), textOf(r));
|
||||||
|
String json = textOf(r);
|
||||||
|
assertTrue(json.contains("\"ticket\":\"" + ticket + "\""), json);
|
||||||
|
assertTrue(json.contains("\"phase\":\"PENDING\""), json);
|
||||||
|
assertTrue(json.contains("\"target\":\"term_worker\""), json);
|
||||||
|
}
|
||||||
|
|
||||||
// --- acceptance 7 (wiring) is covered by FleetdLeadRolloverWiringTest, unchanged -----------
|
// --- acceptance 7 (wiring) is covered by FleetdLeadRolloverWiringTest, unchanged -----------
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class FleetMcpLeadContextGaugeWiringTest {
|
|||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(),
|
FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(),
|
||||||
FleetMcp.LeadSeatSource.none(), contextGauge, leadConfigDirs,
|
FleetMcp.LeadSeatSource.none(), contextGauge, leadConfigDirs,
|
||||||
Map.of(LEAD_TERMINAL, LEAD_NAME), LEAD_TERMINAL, FleetMcp.CoordinationSource.none(), false);
|
Map.of(LEAD_TERMINAL, LEAD_NAME), LEAD_TERMINAL, Map.of(), false,
|
||||||
|
FleetMcp.CoordinationSource.none(), false, true, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
package dev.ltms.fleet.mcp;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.Fleetd;
|
||||||
import dev.ltms.fleet.auth.CallerResolver;
|
import dev.ltms.fleet.auth.CallerResolver;
|
||||||
import dev.ltms.fleet.auth.MemberRegistry;
|
import dev.ltms.fleet.auth.MemberRegistry;
|
||||||
import dev.ltms.fleet.auth.Principal;
|
import dev.ltms.fleet.auth.Principal;
|
||||||
@@ -10,6 +11,7 @@ import dev.ltms.fleet.herdr.AgentControl;
|
|||||||
import dev.ltms.fleet.herdr.AgentStatus;
|
import dev.ltms.fleet.herdr.AgentStatus;
|
||||||
import dev.ltms.fleet.herdr.FakeHerdr;
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
import dev.ltms.fleet.inject.LoopWatchdog;
|
import dev.ltms.fleet.inject.LoopWatchdog;
|
||||||
|
import dev.ltms.fleet.lead.LeadContextGauge;
|
||||||
import dev.ltms.fleet.herdr.PaneLocator;
|
import dev.ltms.fleet.herdr.PaneLocator;
|
||||||
import dev.ltms.fleet.herdr.WorkspaceControl;
|
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||||
import dev.ltms.fleet.inject.Injector;
|
import dev.ltms.fleet.inject.Injector;
|
||||||
@@ -76,7 +78,7 @@ class FleetMcpTest {
|
|||||||
|
|
||||||
private void assertSendRoundTrips(String target, Set<String> profiles) throws Exception {
|
private void assertSendRoundTrips(String target, Set<String> profiles) throws Exception {
|
||||||
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
||||||
() -> FleetMcp.send(messages, target, "hi", 4000L, null, profiles));
|
() -> FleetMcp.send(messages, target, "hi", 4000L, null, profiles, null));
|
||||||
long deadline = System.currentTimeMillis() + 3000;
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
while (!rendezvous.isWaiting(target) && System.currentTimeMillis() < deadline) {
|
while (!rendezvous.isWaiting(target) && System.currentTimeMillis() < deadline) {
|
||||||
Thread.sleep(5);
|
Thread.sleep(5);
|
||||||
@@ -102,7 +104,7 @@ class FleetMcpTest {
|
|||||||
void sendThenReplyRoundTrips() throws Exception {
|
void sendThenReplyRoundTrips() throws Exception {
|
||||||
// fleet_send blocks; fleet_reply resolves it with the worker's structured answer.
|
// fleet_send blocks; fleet_reply resolves it with the worker's structured answer.
|
||||||
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
||||||
() -> FleetMcp.send(messages, "term_a", "review this", 4000L, null, Set.of()));
|
() -> FleetMcp.send(messages, "term_a", "review this", 4000L, null, Set.of(), null));
|
||||||
|
|
||||||
// Wait until the send has opened its waiter so the reply resolves it (CB-307: reply now
|
// Wait until the send has opened its waiter so the reply resolves it (CB-307: reply now
|
||||||
// queues in the inbox if no waiter is open, which would break the round-trip).
|
// queues in the inbox if no waiter is open, which would break the round-trip).
|
||||||
@@ -180,7 +182,7 @@ class FleetMcpTest {
|
|||||||
String turnId = afterTurnId.substring(0, afterTurnId.indexOf('"'));
|
String turnId = afterTurnId.substring(0, afterTurnId.indexOf('"'));
|
||||||
|
|
||||||
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
||||||
() -> FleetMcp.answer(messages, turnId, "config.yaml", 5000L));
|
() -> FleetMcp.answer(messages, turnId, "config.yaml", 5000L, null));
|
||||||
assertEquals("config.yaml", textOf(ask.get(6, TimeUnit.SECONDS)));
|
assertEquals("config.yaml", textOf(ask.get(6, TimeUnit.SECONDS)));
|
||||||
|
|
||||||
deadline = System.currentTimeMillis() + 3000;
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
@@ -278,7 +280,7 @@ class FleetMcpTest {
|
|||||||
assertEquals("late reply", messages.drainReplies("term_a").getFirst().content());
|
assertEquals("late reply", messages.drainReplies("term_a").getFirst().content());
|
||||||
|
|
||||||
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
||||||
() -> FleetMcp.answer(messages, firstTurnId, "config.yaml", 5000L));
|
() -> FleetMcp.answer(messages, firstTurnId, "config.yaml", 5000L, null));
|
||||||
assertEquals("config.yaml", textOf(ask.get(6, TimeUnit.SECONDS)));
|
assertEquals("config.yaml", textOf(ask.get(6, TimeUnit.SECONDS)));
|
||||||
while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) {
|
while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) {
|
||||||
Thread.sleep(5);
|
Thread.sleep(5);
|
||||||
@@ -287,6 +289,91 @@ class FleetMcpTest {
|
|||||||
assertEquals("done", textOf(answer.get(6, TimeUnit.SECONDS)));
|
assertEquals("done", textOf(answer.get(6, TimeUnit.SECONDS)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fleetd #715: fleet_send{turnId} is gated on the caller that owns the turn -------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blocking {@code fleet_send} from one caller opens the turn; a {@code fleet_send{turnId}}
|
||||||
|
* from a different caller is refused as an error, and the real owner's answer still succeeds.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aDifferentCallersMcpAnswerIsRefusedForABlockingSendButTheRealOwnerSucceeds() throws Exception {
|
||||||
|
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
||||||
|
() -> FleetMcp.send(messages, T, "do X", 5000L, null, Set.of(), "term_owner"));
|
||||||
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.isWaiting(T));
|
||||||
|
|
||||||
|
CompletableFuture<McpSchema.CallToolResult> ask = CompletableFuture.supplyAsync(
|
||||||
|
() -> FleetMcp.ask(messages, T, "which config?", 5000L));
|
||||||
|
McpSchema.CallToolResult question = send.get(5, TimeUnit.SECONDS);
|
||||||
|
assertTrue(textOf(question).contains("[question]"), textOf(question));
|
||||||
|
String questionText = textOf(question);
|
||||||
|
String afterTurnId = questionText.substring(questionText.indexOf("turnId=\"") + "turnId=\"".length());
|
||||||
|
String turnId = afterTurnId.substring(0, afterTurnId.indexOf('"'));
|
||||||
|
|
||||||
|
McpSchema.CallToolResult hijacked = FleetMcp.answer(messages, turnId, "evil.yaml", 500L, "term_attacker");
|
||||||
|
assertTrue(hijacked.isError(), "a caller that did not open this turn must get an error, not an answer");
|
||||||
|
assertFalse(ask.isDone(), "a refused answer must not resolve the worker's blocked fleet_ask");
|
||||||
|
|
||||||
|
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
||||||
|
() -> FleetMcp.answer(messages, turnId, "config.yaml", 5000L, "term_owner"));
|
||||||
|
assertEquals("config.yaml", textOf(ask.get(5, TimeUnit.SECONDS)));
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
FleetMcp.reply(messages, T, Role.WORKER, "done");
|
||||||
|
assertEquals("done", textOf(answer.get(5, TimeUnit.SECONDS)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same hijack and control through the fire-and-poll ({@code sendAsync}) path: the owner comes
|
||||||
|
* from the resolved principal, not from a caller argument threaded through a live call.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aDifferentCallersMcpAnswerIsRefusedForAnAsyncSendButTheRealOwnerSucceeds() throws Exception {
|
||||||
|
Principal owner = Principal.worker("term_owner", 1);
|
||||||
|
McpSchema.CallToolResult accepted =
|
||||||
|
FleetMcp.sendAsync(messages, T, "do it", null, Set.of(), owner);
|
||||||
|
String ticket = textOf(accepted).substring(textOf(accepted).indexOf("ticket=") + "ticket=".length()).trim();
|
||||||
|
|
||||||
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.isWaiting(T));
|
||||||
|
|
||||||
|
CompletableFuture<McpSchema.CallToolResult> ask = CompletableFuture.supplyAsync(
|
||||||
|
() -> FleetMcp.ask(messages, T, "which config?", 5000L));
|
||||||
|
MessageService.TaskView asking = messages.poll(ticket);
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (asking.phase() != MessageService.Phase.ASKING && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
asking = messages.poll(ticket);
|
||||||
|
}
|
||||||
|
assertEquals(MessageService.Phase.ASKING, asking.phase());
|
||||||
|
String turnId = asking.turnId();
|
||||||
|
|
||||||
|
McpSchema.CallToolResult hijacked = FleetMcp.answer(messages, turnId, "evil.yaml", 500L,
|
||||||
|
"worker:term_attacker");
|
||||||
|
assertTrue(hijacked.isError(), "a caller that did not create this delegation must get an error");
|
||||||
|
assertFalse(ask.isDone(), "a refused answer must not resolve the worker's blocked fleet_ask");
|
||||||
|
assertEquals(MessageService.Phase.ASKING, messages.poll(ticket).phase(),
|
||||||
|
"a refused answer must not advance the async ticket's phase");
|
||||||
|
|
||||||
|
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
||||||
|
() -> FleetMcp.answer(messages, turnId, "config.yaml", 5000L, owner.ownerKey()));
|
||||||
|
assertEquals("config.yaml", textOf(ask.get(5, TimeUnit.SECONDS)));
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
FleetMcp.reply(messages, T, Role.WORKER, "done");
|
||||||
|
assertEquals("done", textOf(answer.get(5, TimeUnit.SECONDS)));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void pollUnknownTicketIsAnError() {
|
void pollUnknownTicketIsAnError() {
|
||||||
McpSchema.CallToolResult res = FleetMcp.poll(messages, "task-999", null);
|
McpSchema.CallToolResult res = FleetMcp.poll(messages, "task-999", null);
|
||||||
@@ -296,7 +383,7 @@ class FleetMcpTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sendTimesOutWithAWorkingNote() {
|
void sendTimesOutWithAWorkingNote() {
|
||||||
McpSchema.CallToolResult res = FleetMcp.send(messages, "term_a", "hi", 120L, null, Set.of());
|
McpSchema.CallToolResult res = FleetMcp.send(messages, "term_a", "hi", 120L, null, Set.of(), null);
|
||||||
assertNotEquals(Boolean.TRUE, res.isError(), "a timeout is informational, not a tool error");
|
assertNotEquals(Boolean.TRUE, res.isError(), "a timeout is informational, not a tool error");
|
||||||
assertTrue(textOf(res).contains("no reply"), "got: " + textOf(res));
|
assertTrue(textOf(res).contains("no reply"), "got: " + textOf(res));
|
||||||
}
|
}
|
||||||
@@ -312,7 +399,7 @@ class FleetMcpTest {
|
|||||||
void sendTimesOutWithAnUnconfirmedNoteNotARetryInvitation() throws Exception {
|
void sendTimesOutWithAnUnconfirmedNoteNotARetryInvitation() throws Exception {
|
||||||
herdr.agentSendFailsWith("send_failed");
|
herdr.agentSendFailsWith("send_failed");
|
||||||
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
||||||
() -> FleetMcp.send(messages, T, "hi", 150L, null, Set.of()));
|
() -> FleetMcp.send(messages, T, "hi", 150L, null, Set.of(), null));
|
||||||
long deadline = System.currentTimeMillis() + 2000;
|
long deadline = System.currentTimeMillis() + 2000;
|
||||||
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
||||||
//noinspection BusyWait
|
//noinspection BusyWait
|
||||||
@@ -332,13 +419,13 @@ class FleetMcpTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sendRejectsMissingArgs() {
|
void sendRejectsMissingArgs() {
|
||||||
assertTrue(FleetMcp.send(messages, null, "hi", null, null, Set.of()).isError());
|
assertTrue(FleetMcp.send(messages, null, "hi", null, null, Set.of(), null).isError());
|
||||||
assertTrue(FleetMcp.send(messages, "term_a", " ", null, null, Set.of()).isError());
|
assertTrue(FleetMcp.send(messages, "term_a", " ", null, null, Set.of(), null).isError());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sendRejectsAConfiguredProfileNameBeforeAcceptingIt() {
|
void sendRejectsAConfiguredProfileNameBeforeAcceptingIt() {
|
||||||
McpSchema.CallToolResult blocking = FleetMcp.send(messages, "sol", "hi", 100L, null, Set.of("sol"));
|
McpSchema.CallToolResult blocking = FleetMcp.send(messages, "sol", "hi", 100L, null, Set.of("sol"), null);
|
||||||
McpSchema.CallToolResult async = FleetMcp.sendAsync(messages, "sol", "hi", null, Set.of("sol"));
|
McpSchema.CallToolResult async = FleetMcp.sendAsync(messages, "sol", "hi", null, Set.of("sol"));
|
||||||
|
|
||||||
assertTrue(blocking.isError());
|
assertTrue(blocking.isError());
|
||||||
@@ -357,7 +444,7 @@ class FleetMcpTest {
|
|||||||
assertSendRoundTrips("term_live_member", profiles);
|
assertSendRoundTrips("term_live_member", profiles);
|
||||||
|
|
||||||
// A herdr-owned pane outside the bridge roster cannot be classified at accept time.
|
// A herdr-owned pane outside the bridge roster cannot be classified at accept time.
|
||||||
McpSchema.CallToolResult result = FleetMcp.send(messages, "external-pane", "hi", 10L, null, profiles);
|
McpSchema.CallToolResult result = FleetMcp.send(messages, "external-pane", "hi", 10L, null, profiles, null);
|
||||||
assertFalse(result.isError(), "an unclassified target must not be rejected at acceptance time");
|
assertFalse(result.isError(), "an unclassified target must not be rejected at acceptance time");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +536,7 @@ class FleetMcpTest {
|
|||||||
void askThenAnswerRoundTrips() throws Exception {
|
void askThenAnswerRoundTrips() throws Exception {
|
||||||
// The primary delegates and blocks; wait until its waiter is open before the worker asks.
|
// The primary delegates and blocks; wait until its waiter is open before the worker asks.
|
||||||
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
||||||
() -> FleetMcp.send(messages, "term_a", "do X", 5000L, null, Set.of()));
|
() -> FleetMcp.send(messages, "term_a", "do X", 5000L, null, Set.of(), null));
|
||||||
long deadline = System.currentTimeMillis() + 3000;
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) {
|
while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) {
|
||||||
//noinspection BusyWait
|
//noinspection BusyWait
|
||||||
@@ -471,7 +558,7 @@ class FleetMcpTest {
|
|||||||
|
|
||||||
// The primary answers via fleet_send(turnId); this blocks again for the worker's reply.
|
// The primary answers via fleet_send(turnId); this blocks again for the worker's reply.
|
||||||
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
||||||
() -> FleetMcp.answer(messages, turnId, "config.yaml", 5000L));
|
() -> FleetMcp.answer(messages, turnId, "config.yaml", 5000L, null));
|
||||||
|
|
||||||
// The worker's ask returns the answer — it resumes the same turn.
|
// The worker's ask returns the answer — it resumes the same turn.
|
||||||
assertEquals("config.yaml", textOf(ask.get(6, TimeUnit.SECONDS)));
|
assertEquals("config.yaml", textOf(ask.get(6, TimeUnit.SECONDS)));
|
||||||
@@ -497,7 +584,7 @@ class FleetMcpTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void answerToAStaleTurnIsAnError() {
|
void answerToAStaleTurnIsAnError() {
|
||||||
McpSchema.CallToolResult res = FleetMcp.answer(messages, "term_a#999", "too late", 500L);
|
McpSchema.CallToolResult res = FleetMcp.answer(messages, "term_a#999", "too late", 500L, null);
|
||||||
assertTrue(res.isError());
|
assertTrue(res.isError());
|
||||||
assertTrue(textOf(res).contains("no longer open"), textOf(res));
|
assertTrue(textOf(res).contains("no longer open"), textOf(res));
|
||||||
}
|
}
|
||||||
@@ -691,7 +778,7 @@ class FleetMcpTest {
|
|||||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true);
|
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true, true, true);
|
||||||
|
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
// fleetd #361: reports both which coord-id a peer must use to reach ME, and this daemon's
|
// fleetd #361: reports both which coord-id a peer must use to reach ME, and this daemon's
|
||||||
@@ -720,7 +807,7 @@ class FleetMcpTest {
|
|||||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true);
|
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true, true, true);
|
||||||
|
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
assertTrue(out.contains("\"mailbox\":{\"status\":\"unknown\"}"), out);
|
assertTrue(out.contains("\"mailbox\":{\"status\":\"unknown\"}"), out);
|
||||||
@@ -779,19 +866,82 @@ class FleetMcpTest {
|
|||||||
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
||||||
FakeLeadChannel channel = new FakeLeadChannel("mac-opus")
|
FakeLeadChannel channel = new FakeLeadChannel("mac-opus")
|
||||||
.withMailbox("mac-opus", LeadChannel.MailboxState.exists("mac-opus", 0, 1));
|
.withMailbox("mac-opus", LeadChannel.MailboxState.exists("mac-opus", 0, 1));
|
||||||
boolean callerIsPrimary = Principal.worker("term_a", 1).isPrimary();
|
Principal worker = Principal.worker("term_a", 1);
|
||||||
|
|
||||||
McpSchema.CallToolResult res = FleetMcp.listFleet(
|
McpSchema.CallToolResult res = FleetMcp.listFleet(
|
||||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), callerIsPrimary);
|
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), worker.isPrimary(),
|
||||||
|
FleetMcp.leadsVisibleTo(worker), FleetMcp.membersVisibleTo(worker));
|
||||||
|
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
assertFalse(out.contains("\"coordinator\""), "a worker must never see the coordinator key at all: " + out);
|
assertFalse(out.contains("\"coordinator\""), "a worker must never see the coordinator key at all: " + out);
|
||||||
assertFalse(out.contains("mac-opus"), "no fragment of the coordinator row may leak either: " + out);
|
assertFalse(out.contains("mac-opus"), "no fragment of the coordinator row may leak either: " + out);
|
||||||
assertTrue(out.contains("\"leads\""), "the rest of the result must still be present: " + out);
|
assertFalse(out.contains("\"leads\""), "a worker must never see the leads key at all: " + out);
|
||||||
assertTrue(out.contains("\"members\""), out);
|
assertFalse(out.contains("\"members\""), "a worker must never see the members key at all: " + out);
|
||||||
|
assertTrue(out.contains("\"healthCoverage\""), "the rest of the result must still be present: " + out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A worker's result must carry neither the {@code leads} nor the {@code members} key, and no
|
||||||
|
* fragment of either row leaks even though both are fully populated for this call -- a key
|
||||||
|
* check alone would pass on an implementation that still built the rows and only renamed or
|
||||||
|
* nested them.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listLeaksNoLeadOrMemberRowFragmentToAWorker() {
|
||||||
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
||||||
|
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), worktrees);
|
||||||
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", "term_owner",
|
||||||
|
new WorktreeRequest("cb-304", null));
|
||||||
|
Principal worker = Principal.worker("term_a", 1);
|
||||||
|
|
||||||
|
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.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
|
Map.of("term_lead_x", "mac-opus"), "", FleetMcp.CoordinationSource.none(), worker.isPrimary(),
|
||||||
|
FleetMcp.leadsVisibleTo(worker), FleetMcp.membersVisibleTo(worker));
|
||||||
|
|
||||||
|
String out = textOf(res);
|
||||||
|
assertFalse(out.contains("\"leads\""), out);
|
||||||
|
assertFalse(out.contains("\"members\""), out);
|
||||||
|
assertFalse(out.contains(s.terminalId()), "no member row fragment may leak to a worker: " + out);
|
||||||
|
assertFalse(out.contains("term_owner"), "no member owner fragment may leak to a worker: " + out);
|
||||||
|
assertFalse(out.contains("mac-opus"), "no lead row fragment may leak to a worker: " + out);
|
||||||
|
assertTrue(out.contains("\"healthCoverage\""), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaborator may {@code SEND} to a lead, so it must see the {@code leads} array -- the
|
||||||
|
* only place {@code fleet_whoami} does not already give it a lead's address. It may never
|
||||||
|
* {@code SEND} to a spawned member, so the {@code members} key must stay absent for it, with
|
||||||
|
* no fragment of a populated member row leaking either.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listShowsLeadsButNotMembersToACollaborator() {
|
||||||
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
||||||
|
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), worktrees);
|
||||||
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", "term_owner",
|
||||||
|
new WorktreeRequest("cb-304", null));
|
||||||
|
Principal collaborator = Principal.collaborator("ops", "term_collab", 600);
|
||||||
|
|
||||||
|
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.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
|
Map.of("term_lead_x", "mac-opus"), "", FleetMcp.CoordinationSource.none(), collaborator.isPrimary(),
|
||||||
|
FleetMcp.leadsVisibleTo(collaborator), FleetMcp.membersVisibleTo(collaborator));
|
||||||
|
|
||||||
|
String out = textOf(res);
|
||||||
|
assertTrue(out.contains("\"leads\""), "a collaborator must see the leads array: " + out);
|
||||||
|
assertTrue(out.contains("mac-opus"), "a collaborator must see the lead's name/address: " + out);
|
||||||
|
assertFalse(out.contains("\"members\""), "a collaborator must never see the members key: " + out);
|
||||||
|
assertFalse(out.contains(s.terminalId()), "no member row fragment may leak to a collaborator: " + out);
|
||||||
|
assertFalse(out.contains("term_owner"), "no member owner fragment may leak to a collaborator: " + out);
|
||||||
assertTrue(out.contains("\"healthCoverage\""), out);
|
assertTrue(out.contains("\"healthCoverage\""), out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,16 +957,19 @@ class FleetMcpTest {
|
|||||||
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
||||||
FakeLeadChannel channel = new FakeLeadChannel("mac-opus")
|
FakeLeadChannel channel = new FakeLeadChannel("mac-opus")
|
||||||
.withMailbox("mac-opus", LeadChannel.MailboxState.exists("mac-opus", 0, 1));
|
.withMailbox("mac-opus", LeadChannel.MailboxState.exists("mac-opus", 0, 1));
|
||||||
boolean callerIsPrimary = Principal.architect("lead-designer", "term_design", 400).isPrimary();
|
Principal architect = Principal.architect("lead-designer", "term_design", 400);
|
||||||
|
|
||||||
McpSchema.CallToolResult res = FleetMcp.listFleet(
|
McpSchema.CallToolResult res = FleetMcp.listFleet(
|
||||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), callerIsPrimary);
|
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), architect.isPrimary(),
|
||||||
|
FleetMcp.leadsVisibleTo(architect), FleetMcp.membersVisibleTo(architect));
|
||||||
|
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
assertFalse(out.contains("\"coordinator\""), "an architect must never see the coordinator key either: " + out);
|
assertFalse(out.contains("\"coordinator\""), "an architect must never see the coordinator key either: " + out);
|
||||||
|
assertTrue(out.contains("\"leads\""), "an architect must still see the leads array: " + out);
|
||||||
|
assertTrue(out.contains("\"members\""), "an architect must still see the members array: " + out);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -841,7 +994,7 @@ class FleetMcpTest {
|
|||||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true));
|
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true, true, true));
|
||||||
|
|
||||||
assertTrue(gatedAsPrimary.contains("\"coordinator\""), gatedAsPrimary);
|
assertTrue(gatedAsPrimary.contains("\"coordinator\""), gatedAsPrimary);
|
||||||
assertTrue(gatedAsPrimary.contains("\"selfId\":\"mac-opus\""), gatedAsPrimary);
|
assertTrue(gatedAsPrimary.contains("\"selfId\":\"mac-opus\""), gatedAsPrimary);
|
||||||
@@ -850,6 +1003,8 @@ class FleetMcpTest {
|
|||||||
assertTrue(gatedAsPrimary.contains("\"heldDurable\""), gatedAsPrimary);
|
assertTrue(gatedAsPrimary.contains("\"heldDurable\""), gatedAsPrimary);
|
||||||
assertTrue(gatedAsPrimary.contains("\"held\""), gatedAsPrimary);
|
assertTrue(gatedAsPrimary.contains("\"held\""), gatedAsPrimary);
|
||||||
assertTrue(gatedAsPrimary.contains("\"peers\""), gatedAsPrimary);
|
assertTrue(gatedAsPrimary.contains("\"peers\""), gatedAsPrimary);
|
||||||
|
assertTrue(gatedAsPrimary.contains("\"leads\""), "the primary must still see the leads array: " + gatedAsPrimary);
|
||||||
|
assertTrue(gatedAsPrimary.contains("\"members\""), "the primary must still see the members array: " + gatedAsPrimary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -868,7 +1023,7 @@ class FleetMcpTest {
|
|||||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true);
|
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true, true, true);
|
||||||
|
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
assertTrue(out.contains("\"msgId\":\"m1\""), out);
|
assertTrue(out.contains("\"msgId\":\"m1\""), out);
|
||||||
@@ -879,6 +1034,332 @@ class FleetMcpTest {
|
|||||||
assertTrue(out.contains("x".repeat(80) + "…"), "expected an 80-char preview with an ellipsis: " + out);
|
assertTrue(out.contains("x".repeat(80) + "…"), "expected an 80-char preview with an ellipsis: " + out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fleetd #703: fleet_list's collaborators array -------------------------------------------
|
||||||
|
|
||||||
|
/** Calls the canonical {@code listFleet} overload directly, so a test can set the collaborators
|
||||||
|
* payload and its visibility independently of a real {@code Principal} / MCP exchange. */
|
||||||
|
private static McpSchema.CallToolResult listFleetWithCollaborators(FakeHerdr h,
|
||||||
|
Map<String, String> collaborators, boolean collaboratorsVisible) {
|
||||||
|
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(), "", collaborators, collaboratorsVisible,
|
||||||
|
FleetMcp.CoordinationSource.none(), false, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #703 acceptance A: a visible caller with one configured collaborator gets a
|
||||||
|
* {@code collaborators} row whose key ({@code CallerResolver.collaborators()}'s
|
||||||
|
* {@code terminal_id -> name} entry) lands as that row's {@code sessionId}, and {@code leads}/
|
||||||
|
* {@code members} are unaffected by the new key.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listReportsACollaboratorsRowKeyedByTheCollaboratorsTerminalId() {
|
||||||
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
String out = textOf(listFleetWithCollaborators(h, Map.of("term_collab", "ops"), true));
|
||||||
|
|
||||||
|
assertTrue(out.contains("\"collaborators\":["), out);
|
||||||
|
assertTrue(out.contains("\"name\":\"ops\""), out);
|
||||||
|
assertTrue(out.contains("\"sessionId\":\"term_collab\""), out);
|
||||||
|
assertTrue(out.contains("\"leads\":[]"), out);
|
||||||
|
assertTrue(out.contains("\"members\":[]"), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #703 acceptance A, control half: with no collaborator configured, the key is absent
|
||||||
|
* (caller cannot see it) or an empty array (caller can), and {@code leads}/{@code members} are
|
||||||
|
* unchanged either way.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listOmitsOrEmptiesCollaboratorsWhenNoneAreConfigured() {
|
||||||
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
|
||||||
|
String visibleButEmpty = textOf(listFleetWithCollaborators(h, Map.of(), true));
|
||||||
|
assertTrue(visibleButEmpty.contains("\"collaborators\":[]"), visibleButEmpty);
|
||||||
|
assertTrue(visibleButEmpty.contains("\"leads\":[]"), visibleButEmpty);
|
||||||
|
assertTrue(visibleButEmpty.contains("\"members\":[]"), visibleButEmpty);
|
||||||
|
|
||||||
|
String notVisible = textOf(listFleetWithCollaborators(h, Map.of(), false));
|
||||||
|
assertFalse(notVisible.contains("\"collaborators\""), notVisible);
|
||||||
|
assertTrue(notVisible.contains("\"leads\":[]"), notVisible);
|
||||||
|
assertTrue(notVisible.contains("\"members\":[]"), notVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #703 acceptance B: a worker must not see the {@code collaborators} array at all, while
|
||||||
|
* an architect -- a role that also holds READ, same as a worker -- does see it. Both halves are
|
||||||
|
* asserted: a test that only checked the worker-hidden half would pass even if the feature were
|
||||||
|
* never wired up for anyone.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listOmitsCollaboratorsForAWorkerAndIncludesThemForAnArchitect() {
|
||||||
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
Map<String, String> collaborators = Map.of("term_collab", "ops");
|
||||||
|
|
||||||
|
String asWorker = textOf(listFleetWithCollaborators(h, collaborators,
|
||||||
|
FleetMcp.collaboratorsVisibleTo(Principal.worker("term_w", 1))));
|
||||||
|
assertFalse(asWorker.contains("\"collaborators\""),
|
||||||
|
"a worker must not see the collaborators array: " + asWorker);
|
||||||
|
|
||||||
|
String asArchitect = textOf(listFleetWithCollaborators(h, collaborators,
|
||||||
|
FleetMcp.collaboratorsVisibleTo(Principal.architect("design", "term_arch", 2))));
|
||||||
|
assertTrue(asArchitect.contains("\"collaborators\":["),
|
||||||
|
"an architect must see the collaborators array: " + asArchitect);
|
||||||
|
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
|
* 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
|
* normal, healthy state is {@code "pending": 0} next to a non-empty {@code held[]} — which
|
||||||
@@ -900,7 +1381,7 @@ class FleetMcpTest {
|
|||||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true);
|
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true, true, true);
|
||||||
|
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
assertTrue(out.contains("\"pending\":0"), out);
|
assertTrue(out.contains("\"pending\":0"), out);
|
||||||
@@ -929,7 +1410,7 @@ class FleetMcpTest {
|
|||||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true);
|
Map.of(), "", new FleetMcp.CoordinationSource(channel, List.of()), true, true, true);
|
||||||
|
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
assertTrue(out.contains("\"heldDurable\":false"),
|
assertTrue(out.contains("\"heldDurable\":false"),
|
||||||
@@ -998,7 +1479,7 @@ class FleetMcpTest {
|
|||||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||||
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(),
|
||||||
Map.of(), "",
|
Map.of(), "",
|
||||||
new FleetMcp.CoordinationSource(channel, List.of("fleet01-lead", "fleet02-lead", "fleet03-lead")), true);
|
new FleetMcp.CoordinationSource(channel, List.of("fleet01-lead", "fleet02-lead", "fleet03-lead")), true, true, true);
|
||||||
|
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
assertTrue(out.contains("\"coordId\":\"fleet01-lead\",\"status\":\"exists\",\"pending\":2,\"consumers\":1"), out);
|
assertTrue(out.contains("\"coordId\":\"fleet01-lead\",\"status\":\"exists\",\"pending\":2,\"consumers\":1"), out);
|
||||||
@@ -1691,8 +2172,8 @@ class FleetMcpTest {
|
|||||||
Principal architect = Principal.architect("lead-designer", "term_design", 400);
|
Principal architect = Principal.architect("lead-designer", "term_design", 400);
|
||||||
MemberPresence presence = new MemberPresence();
|
MemberPresence presence = new MemberPresence();
|
||||||
|
|
||||||
FleetMcp.markSpawnedMemberPresent(worker, presence);
|
FleetMcp.markTrackedCallerPresent(worker, presence);
|
||||||
FleetMcp.markSpawnedMemberPresent(architect, presence);
|
FleetMcp.markTrackedCallerPresent(architect, presence);
|
||||||
|
|
||||||
assertTrue(presence.isPresent("term_worker"));
|
assertTrue(presence.isPresent("term_worker"));
|
||||||
assertTrue(presence.isPresent("term_design"));
|
assertTrue(presence.isPresent("term_design"));
|
||||||
@@ -1703,25 +2184,40 @@ class FleetMcpTest {
|
|||||||
Principal lead = Principal.leader("opus", "term_lead", 100);
|
Principal lead = Principal.leader("opus", "term_lead", 100);
|
||||||
MemberPresence presence = new MemberPresence();
|
MemberPresence presence = new MemberPresence();
|
||||||
|
|
||||||
FleetMcp.markSpawnedMemberPresent(lead, presence);
|
FleetMcp.markTrackedCallerPresent(lead, presence);
|
||||||
FleetMcp.markSpawnedMemberPresent(Principal.anonymous(), presence);
|
FleetMcp.markTrackedCallerPresent(Principal.anonymous(), presence);
|
||||||
|
|
||||||
assertFalse(presence.isPresent("term_lead"));
|
assertFalse(presence.isPresent("term_lead"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item whose absence would be silent: an observer's own MCP contact must still mark
|
||||||
|
* presence, or a pane resolving to the unconfigured-pane floor would sit on the injector
|
||||||
|
* readiness gate forever once something addresses it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void anObserverContactMarksPresence() {
|
||||||
|
Principal observer = Principal.observer("term_observer", 800);
|
||||||
|
MemberPresence presence = new MemberPresence();
|
||||||
|
|
||||||
|
FleetMcp.markTrackedCallerPresent(observer, presence);
|
||||||
|
|
||||||
|
assertTrue(presence.isPresent("term_observer"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void statusReportsLiveAgentStatus() {
|
void statusReportsLiveAgentStatus() {
|
||||||
FakeHerdr blocked = new FakeHerdr().agentStatus("blocked");
|
FakeHerdr blocked = new FakeHerdr().agentStatus("blocked");
|
||||||
AgentControl blockedAgents = new AgentControl(blocked);
|
AgentControl blockedAgents = new AgentControl(blocked);
|
||||||
McpSchema.CallToolResult res = FleetMcp.status(
|
McpSchema.CallToolResult res = FleetMcp.status(
|
||||||
new MessageService(blockedAgents, new Injector(blockedAgents), rendezvous), "term_a");
|
new MessageService(blockedAgents, new Injector(blockedAgents), rendezvous), "term_a", null);
|
||||||
assertNotEquals(Boolean.TRUE, res.isError());
|
assertNotEquals(Boolean.TRUE, res.isError());
|
||||||
assertEquals("blocked", textOf(res));
|
assertEquals("blocked", textOf(res));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CB-582: a lead polling {@code fleet_status} on its normal cadence — not {@code fleet_poll}
|
* A lead polling {@code fleet_status} on its normal cadence — not {@code fleet_poll} — must
|
||||||
* — must also see a worker's open async {@code fleet_ask} question, since the reverse-rendezvous
|
* also see a worker's open async {@code fleet_ask} question, since the reverse-rendezvous
|
||||||
* window it opened with is far shorter than that cadence.
|
* window it opened with is far shorter than that cadence.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
@@ -1744,7 +2240,7 @@ class FleetMcpTest {
|
|||||||
} while (asking.phase() != MessageService.Phase.ASKING && System.currentTimeMillis() < deadline);
|
} while (asking.phase() != MessageService.Phase.ASKING && System.currentTimeMillis() < deadline);
|
||||||
assertEquals(MessageService.Phase.ASKING, asking.phase());
|
assertEquals(MessageService.Phase.ASKING, asking.phase());
|
||||||
|
|
||||||
McpSchema.CallToolResult res = FleetMcp.status(messages, T);
|
McpSchema.CallToolResult res = FleetMcp.status(messages, T, null);
|
||||||
assertNotEquals(Boolean.TRUE, res.isError());
|
assertNotEquals(Boolean.TRUE, res.isError());
|
||||||
String out = textOf(res);
|
String out = textOf(res);
|
||||||
assertTrue(out.startsWith("idle"), "the live status must still lead the text: " + out);
|
assertTrue(out.startsWith("idle"), "the live status must still lead the text: " + out);
|
||||||
@@ -1755,7 +2251,73 @@ class FleetMcpTest {
|
|||||||
// Clean up the still-open ask so the background thread does not linger past the test.
|
// Clean up the still-open ask so the background thread does not linger past the test.
|
||||||
String turnId = asking.turnId();
|
String turnId = asking.turnId();
|
||||||
CompletableFuture<MessageService.Reply> answer = CompletableFuture.supplyAsync(
|
CompletableFuture<MessageService.Reply> answer = CompletableFuture.supplyAsync(
|
||||||
() -> messages.answer(turnId, "config.yaml", 5000));
|
() -> messages.answer(turnId, "config.yaml", 5000, null));
|
||||||
|
assertEquals("config.yaml", ask.get(5, TimeUnit.SECONDS).answer());
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.resolve(T, "done"));
|
||||||
|
answer.get(5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_status}'s pending-ask block (the question, its {@code turnId} and its ticket)
|
||||||
|
* is shown only to the caller whose owner key created the delegation. An unnamed primary is
|
||||||
|
* held to the same rule: its owner key is {@code null}, which here does not match the named
|
||||||
|
* worker that created the delegation, so it sees none of the pending-ask fields either — the
|
||||||
|
* same as any other non-creating caller.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void statusGatesThePendingAskFieldsByTheDelegationsCreatorOwner() throws Exception {
|
||||||
|
Principal creator = Principal.worker("term_creator", 1);
|
||||||
|
String ticket = messages.sendAsync(T, "task that asks", null, creator);
|
||||||
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.isWaiting(T), "sendAsync should have opened its rendezvous waiter");
|
||||||
|
|
||||||
|
CompletableFuture<MessageService.AskResult> ask =
|
||||||
|
CompletableFuture.supplyAsync(() -> messages.ask(T, "which config file?", 5000));
|
||||||
|
|
||||||
|
MessageService.TaskView asking;
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
do {
|
||||||
|
asking = messages.poll(ticket);
|
||||||
|
Thread.sleep(5);
|
||||||
|
} while (asking.phase() != MessageService.Phase.ASKING && System.currentTimeMillis() < deadline);
|
||||||
|
assertEquals(MessageService.Phase.ASKING, asking.phase());
|
||||||
|
|
||||||
|
String other = textOf(FleetMcp.status(messages, T, "worker:term_other"));
|
||||||
|
assertTrue(other.startsWith("idle"), "the base status must still be shown: " + other);
|
||||||
|
assertFalse(other.contains("which config file?"),
|
||||||
|
"a non-creating caller must not see the question text: " + other);
|
||||||
|
assertFalse(other.contains(asking.turnId()),
|
||||||
|
"a non-creating caller must not see the turnId: " + other);
|
||||||
|
assertFalse(other.contains(ticket),
|
||||||
|
"a non-creating caller must not see the ticket: " + other);
|
||||||
|
|
||||||
|
String creatorStatus = textOf(FleetMcp.status(messages, T, creator.ownerKey()));
|
||||||
|
assertTrue(creatorStatus.contains("which config file?"),
|
||||||
|
"the creator must see the question: " + creatorStatus);
|
||||||
|
assertTrue(creatorStatus.contains(asking.turnId()),
|
||||||
|
"the creator must see the turnId: " + creatorStatus);
|
||||||
|
assertTrue(creatorStatus.contains(ticket), "the creator must see the ticket: " + creatorStatus);
|
||||||
|
|
||||||
|
String unnamed = textOf(FleetMcp.status(messages, T, null));
|
||||||
|
assertTrue(unnamed.startsWith("idle"), "the base status must still be shown: " + unnamed);
|
||||||
|
assertFalse(unnamed.contains("which config file?"),
|
||||||
|
"an unnamed primary must not see a question on a delegation a named worker created: " + unnamed);
|
||||||
|
assertFalse(unnamed.contains(asking.turnId()),
|
||||||
|
"a non-creating unnamed primary must not see the turnId: " + unnamed);
|
||||||
|
assertFalse(unnamed.contains(ticket),
|
||||||
|
"a non-creating unnamed primary must not see the ticket: " + unnamed);
|
||||||
|
|
||||||
|
// Clean up the still-open ask so the background thread does not linger past the test.
|
||||||
|
String turnId = asking.turnId();
|
||||||
|
CompletableFuture<MessageService.Reply> answer = CompletableFuture.supplyAsync(
|
||||||
|
() -> messages.answer(turnId, "config.yaml", 5000, creator.ownerKey()));
|
||||||
assertEquals("config.yaml", ask.get(5, TimeUnit.SECONDS).answer());
|
assertEquals("config.yaml", ask.get(5, TimeUnit.SECONDS).answer());
|
||||||
deadline = System.currentTimeMillis() + 3000;
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
while (!rendezvous.isWaiting(T) && System.currentTimeMillis() < deadline) {
|
||||||
@@ -1837,6 +2399,53 @@ class FleetMcpTest {
|
|||||||
assertTrue(out.contains("\"sessionId\":\"term_design\""), out);
|
assertTrue(out.contains("\"sessionId\":\"term_design\""), out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaborator reports its own role and name, never the {@code leader} key a lead gets —
|
||||||
|
* {@code role} already reads {@code "collaborator"}, so a {@code leader} key alongside it
|
||||||
|
* would be self-contradicting. Control: the same call shape fed a named lead must still carry
|
||||||
|
* {@code leader}, so this is not passing because the key stopped being emitted for everyone.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void whoamiReportsACollaboratorWithNoLeaderKeyButALeadStillGetsOne() {
|
||||||
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||||
|
|
||||||
|
McpSchema.CallToolResult collabRes = FleetMcp.whoami(
|
||||||
|
Principal.collaborator("ops", "term_collab", 700), sessions);
|
||||||
|
assertNotEquals(Boolean.TRUE, collabRes.isError());
|
||||||
|
String collabOut = textOf(collabRes);
|
||||||
|
assertTrue(collabOut.contains("\"role\":\"collaborator\""), collabOut);
|
||||||
|
assertTrue(collabOut.contains("\"collaborator\":\"ops\""), collabOut);
|
||||||
|
assertTrue(collabOut.contains("\"sessionId\":\"term_collab\""), collabOut);
|
||||||
|
assertFalse(collabOut.contains("leader"), collabOut);
|
||||||
|
|
||||||
|
McpSchema.CallToolResult leadRes = FleetMcp.whoami(
|
||||||
|
Principal.leader("opus", "term_lead", 100), sessions);
|
||||||
|
String leadOut = textOf(leadRes);
|
||||||
|
assertTrue(leadOut.contains("\"leader\":\"opus\""), leadOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An observer reports its own role and pane, never a {@code leader} key. Without an explicit
|
||||||
|
* branch it would reach the lead branch by elimination and look right only because the
|
||||||
|
* {@code leader} key is guarded on a non-null name — this pins the branch rather than the
|
||||||
|
* accident.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void whoamiReportsAnObserverNotALead() {
|
||||||
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||||
|
|
||||||
|
McpSchema.CallToolResult res = FleetMcp.whoami(
|
||||||
|
Principal.observer("term_observer", 900), sessions);
|
||||||
|
|
||||||
|
assertNotEquals(Boolean.TRUE, res.isError());
|
||||||
|
String out = textOf(res);
|
||||||
|
assertTrue(out.contains("\"role\":\"observer\""), out);
|
||||||
|
assertTrue(out.contains("\"sessionId\":\"term_observer\""), out);
|
||||||
|
assertFalse(out.contains("leader"), out);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CB-548: an architect SEND delegates as its own pane (recording the per-target delegation) but
|
* CB-548: an architect SEND delegates as its own pane (recording the per-target delegation) but
|
||||||
* must NEVER become the legacy singleton "primary" fallback — the per-target map does not cure
|
* must NEVER become the legacy singleton "primary" fallback — the per-target map does not cure
|
||||||
|
|||||||
@@ -169,4 +169,101 @@ class PrimaryRegistryTest {
|
|||||||
assertTrue(reg.nudgeTargetFor("term_worker").isEmpty());
|
assertTrue(reg.nudgeTargetFor("term_worker").isEmpty());
|
||||||
assertTrue(reg.nudgeTargetFor(null).isEmpty());
|
assertTrue(reg.nudgeTargetFor(null).isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #737 unit 3: a named lead's terminal is resolved live, not just recorded ─────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole point of carrying a name: a lead that has been rolled keeps its name but gets a
|
||||||
|
* fresh terminal. {@code currentTerminalForName} stands in for the live lead-tab scan here —
|
||||||
|
* it reports the lead now sits on a different terminal than the one that was recorded — and
|
||||||
|
* {@code nudgeTargetFor} must follow the name to that current terminal, not the stale one.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void nudgeTargetForFollowsARolledLeadsNameToItsCurrentTerminal() {
|
||||||
|
var reg = new PrimaryRegistry(null, name -> "opus".equals(name) ? "term_opus_after_roll" : null);
|
||||||
|
reg.recordDelegation("term_worker", "term_opus_before_roll", "opus");
|
||||||
|
|
||||||
|
assertEquals("term_opus_after_roll", reg.nudgeTargetFor("term_worker").orElseThrow(),
|
||||||
|
"the name must be resolved to the lead's CURRENT terminal, not the one recorded "
|
||||||
|
+ "at delegation time");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lookup cannot place every name — an architect/collaborator name (never a lead), or a lead
|
||||||
|
* whose tab the scan cannot currently see (just rolled, off-host, non-herdr). Either way the
|
||||||
|
* terminal actually recorded is still the right thing to try, exactly as before this unit.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void nudgeTargetForFallsBackToTheRecordedTerminalWhenTheNameCannotBePlaced() {
|
||||||
|
var reg = new PrimaryRegistry(null, name -> null); // nothing is ever currently recognised
|
||||||
|
reg.recordDelegation("term_worker", "term_lead_recorded", "opus");
|
||||||
|
|
||||||
|
assertEquals("term_lead_recorded", reg.nudgeTargetFor("term_worker").orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 2-arg {@code recordDelegation} overload records no name, so resolution never applies. */
|
||||||
|
@Test
|
||||||
|
void recordDelegationWithNoNameIsNeverResolvedByLookup() {
|
||||||
|
var reg = new PrimaryRegistry(null, name -> {
|
||||||
|
throw new AssertionError("a delegation recorded with no name must never consult the lookup");
|
||||||
|
});
|
||||||
|
reg.recordDelegation("term_worker", "term_lead");
|
||||||
|
|
||||||
|
assertEquals("term_lead", reg.nudgeTargetFor("term_worker").orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As {@link #nudgeTargetForFollowsARolledLeadsNameToItsCurrentTerminal}, for the singleton. */
|
||||||
|
@Test
|
||||||
|
void currentPrimaryTerminalFollowsARolledLeadsNameToItsCurrentTerminal() {
|
||||||
|
var reg = new PrimaryRegistry(null, name -> "sol".equals(name) ? "term_sol_after_roll" : null);
|
||||||
|
reg.record("term_sol_before_roll", "sol");
|
||||||
|
|
||||||
|
assertEquals("term_sol_after_roll", reg.currentPrimaryTerminal().orElseThrow());
|
||||||
|
assertEquals("term_sol_before_roll", reg.primaryTerminal().orElseThrow(),
|
||||||
|
"primaryTerminal() stays the raw recorded value — currentPrimaryTerminal() is the "
|
||||||
|
+ "one that resolves live");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void currentPrimaryTerminalFallsBackWhenTheNameCannotBePlaced() {
|
||||||
|
var reg = new PrimaryRegistry(null, name -> null);
|
||||||
|
reg.record("term_sol", "sol");
|
||||||
|
|
||||||
|
assertEquals("term_sol", reg.currentPrimaryTerminal().orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void currentPrimaryTerminalWithNoNameRecordedIsTheRawTerminal() {
|
||||||
|
var reg = new PrimaryRegistry(null, name -> {
|
||||||
|
throw new AssertionError("no name was ever recorded, the lookup must not be consulted");
|
||||||
|
});
|
||||||
|
reg.record("term_x");
|
||||||
|
|
||||||
|
assertEquals("term_x", reg.currentPrimaryTerminal().orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void currentPrimaryTerminalIsEmptyWhenNothingWasEverLearned() {
|
||||||
|
var reg = new PrimaryRegistry(null, name -> "anything");
|
||||||
|
assertTrue(reg.currentPrimaryTerminal().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pin never carries a name, so a pinned registry's singleton resolution is always a no-op. */
|
||||||
|
@Test
|
||||||
|
void currentPrimaryTerminalForAPinIsNeverResolvedByLookup() {
|
||||||
|
var reg = new PrimaryRegistry("term_pinned", name -> {
|
||||||
|
throw new AssertionError("a pin carries no name, the lookup must not be consulted");
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals("term_pinned", reg.currentPrimaryTerminal().orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code nudgeTargetFor}'s fallback to the singleton is the resolved one, not the raw one. */
|
||||||
|
@Test
|
||||||
|
void nudgeTargetForWithNoDelegationFallsBackToTheResolvedSingleton() {
|
||||||
|
var reg = new PrimaryRegistry(null, name -> "opus".equals(name) ? "term_opus_after_roll" : null);
|
||||||
|
reg.record("term_opus_before_roll", "opus");
|
||||||
|
|
||||||
|
assertEquals("term_opus_after_roll", reg.nudgeTargetFor("term_never_seen").orElseThrow());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void deliversAHeldMessageToTheLeadPaneAndAcksIt() {
|
void deliversAHeldMessageToTheLeadPaneAndAcksIt() {
|
||||||
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "the merge is blocked"));
|
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();
|
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void redeliveryOfAMessageAlreadyWrittenToThePaneIsAckedWithoutAnotherPaneWrite() {
|
void redeliveryOfAMessageAlreadyWrittenToThePaneIsAckedWithoutAnotherPaneWrite() {
|
||||||
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "recover me"));
|
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));
|
var loop = loop(channel, herdr, Map.of(LEAD_TERM, SELF));
|
||||||
|
|
||||||
loop.tick();
|
loop.tick();
|
||||||
@@ -71,7 +71,7 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void aRedeliveryIsAckedEvenWhileTheLeadIsMidTurn() {
|
void aRedeliveryIsAckedEvenWhileTheLeadIsMidTurn() {
|
||||||
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "recover me"));
|
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));
|
var loop = loop(channel, herdr, Map.of(LEAD_TERM, SELF));
|
||||||
|
|
||||||
loop.tick();
|
loop.tick();
|
||||||
@@ -91,7 +91,7 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void leavesTheMessageUnackedWhenTheLeadIsMidTurn() {
|
void leavesTheMessageUnackedWhenTheLeadIsMidTurn() {
|
||||||
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
|
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();
|
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void leavesTheMessageUnackedWhenNoLeadPaneIsKnown() {
|
void leavesTheMessageUnackedWhenNoLeadPaneIsKnown() {
|
||||||
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
|
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();
|
loop(channel, herdr, Map.of()).tick();
|
||||||
|
|
||||||
@@ -115,7 +115,8 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void leavesTheMessageUnackedWhenHerdrRefusesTheInjection() {
|
void leavesTheMessageUnackedWhenHerdrRefusesTheInjection() {
|
||||||
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
|
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();
|
loop(channel, herdr, Map.of(LEAD_TERM, SELF)).tick();
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void resolvesTheLeadByNameWhenSeveralAreKnown() {
|
void resolvesTheLeadByNameWhenSeveralAreKnown() {
|
||||||
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
|
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.
|
// Two leads on this daemon; only one carries the coord-id the mailbox is owned as.
|
||||||
var leads = new java.util.LinkedHashMap<String, String>();
|
var leads = new java.util.LinkedHashMap<String, String>();
|
||||||
leads.put("term_other", "some-other-lead");
|
leads.put("term_other", "some-other-lead");
|
||||||
@@ -142,7 +143,7 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void holdsWhenSeveralLeadsAreKnownAndNoneCarriesTheCoordId() {
|
void holdsWhenSeveralLeadsAreKnownAndNoneCarriesTheCoordId() {
|
||||||
var channel = new FakeLeadChannel(SELF).hold(new LeadMessage("m1", PEER, SELF, "hello"));
|
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>();
|
var leads = new java.util.LinkedHashMap<String, String>();
|
||||||
leads.put("term_one", "lead-one");
|
leads.put("term_one", "lead-one");
|
||||||
leads.put("term_two", "lead-two");
|
leads.put("term_two", "lead-two");
|
||||||
@@ -159,7 +160,7 @@ class LeadCoordLoopTest {
|
|||||||
var channel = new FakeLeadChannel(SELF)
|
var channel = new FakeLeadChannel(SELF)
|
||||||
.hold(new LeadMessage("m1", PEER, SELF, "first"))
|
.hold(new LeadMessage("m1", PEER, SELF, "first"))
|
||||||
.hold(new LeadMessage("m2", PEER, SELF, "second"));
|
.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));
|
var loop = loop(channel, herdr, Map.of(LEAD_TERM, SELF));
|
||||||
|
|
||||||
loop.tick();
|
loop.tick();
|
||||||
@@ -175,10 +176,51 @@ class LeadCoordLoopTest {
|
|||||||
@Test
|
@Test
|
||||||
void anEmptyMailboxNeverTouchesHerdr() {
|
void anEmptyMailboxNeverTouchesHerdr() {
|
||||||
var channel = new FakeLeadChannel(SELF);
|
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();
|
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");
|
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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
import dev.ltms.fleet.herdr.AgentStatus;
|
import dev.ltms.fleet.herdr.AgentStatus;
|
||||||
import dev.ltms.fleet.herdr.HerdrClient;
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
import dev.ltms.fleet.lead.LeadContextGauge;
|
import dev.ltms.fleet.lead.LeadContextGauge;
|
||||||
@@ -648,6 +649,42 @@ class LeadHeartbeatLoopTest {
|
|||||||
"the notice text must appear exactly once across all three sends: " + herdr.sentTexts());
|
"the notice text must appear exactly once across all three sends: " + herdr.sentTexts());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #737 unit 3: tick() nudges the lead's CURRENT terminal, not the learned one ───────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code tick()} reads {@code primaryRegistry.currentPrimaryTerminal()} both to check the lead's
|
||||||
|
* status and to send the nudge. Here the registry learned the lead's terminal under its name
|
||||||
|
* before a roll; {@code currentTerminalForName} stands in for the live lead-tab scan and reports
|
||||||
|
* the lead now sits on a different terminal. A correct tick must follow the name and nudge the
|
||||||
|
* new terminal — nudging the old one would mean the heartbeat lost the lead across its own roll.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void tickNudgesTheLeadsCurrentTerminalAfterARoll() {
|
||||||
|
var herdr = new FailableHerdrClient("term_lead_after_roll");
|
||||||
|
var now = new AtomicLong(NOW);
|
||||||
|
InMemoryReplyInbox inbox = new InMemoryReplyInbox();
|
||||||
|
inbox.own(WORKER);
|
||||||
|
inbox.publish(WORKER, "m1", "hello");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<MemberSession>[] rosterBox = new List[]{List.of(new MemberSession("p1", WORKER, "prof",
|
||||||
|
MemberRole.DEV, "/cwd", null, 0, 0, 0, MemberSession.State.READY, null, null))};
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
PrimaryRegistry registry = new PrimaryRegistry(null,
|
||||||
|
name -> "opus".equals(name) ? "term_lead_after_roll" : null);
|
||||||
|
registry.record("term_lead_before_roll", "opus");
|
||||||
|
ReplyPushLoop pushLoop = new ReplyPushLoop(registry, agents, inbox, scheduler, 5, 100_000);
|
||||||
|
LeadHeartbeatLoop loop = new LeadHeartbeatLoop(registry, agents, inbox, () -> rosterBox[0], pushLoop,
|
||||||
|
scheduler, now::get, IDLE_AFTER_NANOS, 100_000L, 0);
|
||||||
|
|
||||||
|
loop.tick(); // opens the idle window
|
||||||
|
now.addAndGet(TimeUnit.SECONDS.toNanos(400));
|
||||||
|
loop.tick(); // past the quiet period, pending reply -> INJECT
|
||||||
|
|
||||||
|
assertEquals(List.of("term_lead_after_roll"), herdr.promptTargets(),
|
||||||
|
"the heartbeat must read and nudge the lead's CURRENT terminal, not the one learned "
|
||||||
|
+ "before the roll");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fake herdr client for the four tests above: always reports {@code lead} as IDLE, records the
|
* Fake herdr client for the four tests above: always reports {@code lead} as IDLE, records the
|
||||||
* {@code text} of every {@code agent.prompt} call, and can be told to throw on the very next
|
* {@code text} of every {@code agent.prompt} call, and can be told to throw on the very next
|
||||||
@@ -657,7 +694,10 @@ class LeadHeartbeatLoopTest {
|
|||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private final String lead;
|
private final String lead;
|
||||||
private final List<String> sentTexts = new ArrayList<>();
|
private final List<String> sentTexts = new ArrayList<>();
|
||||||
|
private final List<String> promptTargets = new ArrayList<>();
|
||||||
private boolean throwOnNextSend = false;
|
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) {
|
FailableHerdrClient(String lead) {
|
||||||
this.lead = lead;
|
this.lead = lead;
|
||||||
@@ -667,10 +707,19 @@ class LeadHeartbeatLoopTest {
|
|||||||
throwOnNextSend = true;
|
throwOnNextSend = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void paneTail(String tail) {
|
||||||
|
this.paneTail = tail;
|
||||||
|
}
|
||||||
|
|
||||||
List<String> sentTexts() {
|
List<String> sentTexts() {
|
||||||
return List.copyOf(sentTexts);
|
return List.copyOf(sentTexts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every terminal an {@code agent.prompt} call named, in call order. */
|
||||||
|
List<String> promptTargets() {
|
||||||
|
return List.copyOf(promptTargets);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public JsonNode call(String method, Object params) {
|
public JsonNode call(String method, Object params) {
|
||||||
@@ -680,13 +729,18 @@ class LeadHeartbeatLoopTest {
|
|||||||
.put("terminal_id", lead)
|
.put("terminal_id", lead)
|
||||||
.put("agent_status", "idle"));
|
.put("agent_status", "idle"));
|
||||||
}
|
}
|
||||||
|
if ("agent.read".equals(method)) {
|
||||||
|
return MAPPER.createObjectNode()
|
||||||
|
.set("read", MAPPER.createObjectNode().put("text", paneTail));
|
||||||
|
}
|
||||||
if ("agent.prompt".equals(method)) {
|
if ("agent.prompt".equals(method)) {
|
||||||
|
Map<String, Object> p = params instanceof Map ? (Map<String, Object>) params : Map.of();
|
||||||
if (throwOnNextSend) {
|
if (throwOnNextSend) {
|
||||||
throwOnNextSend = false;
|
throwOnNextSend = false;
|
||||||
throw new RuntimeException("simulated transient herdr send failure");
|
throw new RuntimeException("simulated transient herdr send failure");
|
||||||
}
|
}
|
||||||
Map<String, Object> p = params instanceof Map ? (Map<String, Object>) params : Map.of();
|
|
||||||
sentTexts.add(String.valueOf(p.get("text")));
|
sentTexts.add(String.valueOf(p.get("text")));
|
||||||
|
promptTargets.add(String.valueOf(p.get("target")));
|
||||||
}
|
}
|
||||||
return MAPPER.createObjectNode();
|
return MAPPER.createObjectNode();
|
||||||
}
|
}
|
||||||
@@ -695,4 +749,49 @@ class LeadHeartbeatLoopTest {
|
|||||||
public void close() {
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package dev.ltms.fleet.msg;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins that no file under {@code src/main/java} calls the fail-open
|
||||||
|
* {@link MessageService#poll(String)} overload. That overload skips the ownership check in
|
||||||
|
* {@code MessageService}'s {@code ownsTicket} entirely, so a caller of it can read any session's
|
||||||
|
* ticket. Every production caller must go through {@link MessageService#poll(String, String)}
|
||||||
|
* and pass a {@code callerOwner} explicitly, even when it is {@code null}.
|
||||||
|
*
|
||||||
|
* <p>This reads each file's own source text rather than reflecting on compiled bytecode, because
|
||||||
|
* the risk is a future one-word edit at a call site, not a missing overload.
|
||||||
|
*
|
||||||
|
* <p>The scan below finds a violation by its receiver, {@code messages.poll(}, rather than the
|
||||||
|
* bare method name, so it does not mistake {@link java.util.Queue#poll()} for a violation. That
|
||||||
|
* anchor only covers a {@code MessageService} reached through a variable or field named
|
||||||
|
* {@code messages}, so {@link #everyMessageServiceDeclarationIsNamedMessages} pins the naming
|
||||||
|
* convention the anchor depends on: a declaration under any other name would be invisible to the
|
||||||
|
* scan above, and must turn this second check red instead of passing silently.
|
||||||
|
*/
|
||||||
|
class MessageServicePollUsageTest {
|
||||||
|
|
||||||
|
private static final Path PRODUCTION_SOURCE = Path.of("src/main/java");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noProductionFileCallsTheSingleArgumentPollOverload() throws IOException {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
List<String> twoArgSites = new ArrayList<>();
|
||||||
|
int filesScanned = scanForPollCalls(PRODUCTION_SOURCE, violations, twoArgSites);
|
||||||
|
|
||||||
|
// CONTROL: the scan actually walked files -- a wrong root would otherwise report "no
|
||||||
|
// violations found" having looked at nothing.
|
||||||
|
assertTrue(filesScanned > 0, "control failed: the scan under " + PRODUCTION_SOURCE
|
||||||
|
+ " visited zero .java files -- the path is wrong, so the absence of violations "
|
||||||
|
+ "below proves nothing");
|
||||||
|
|
||||||
|
assertTrue(violations.isEmpty(), "found a call to the fail-open MessageService.poll(String) "
|
||||||
|
+ "overload, which skips the ownership check entirely -- pass a callerOwner "
|
||||||
|
+ "explicitly (even if null) through poll(String, String) instead: " + violations);
|
||||||
|
|
||||||
|
// CONTROL: the arity parser actually finds the two genuine two-argument call sites (the
|
||||||
|
// MCP handler in FleetMcp and the REST handler in FleetApp). If this drops, the parser
|
||||||
|
// itself is broken, not the production code -- a broken parser (or a scan root that
|
||||||
|
// reaches no real source) must fail loudly here rather than pass vacuously above.
|
||||||
|
assertEquals(2, twoArgSites.size(), "control failed: expected exactly the two known "
|
||||||
|
+ "two-argument messages.poll(...) call sites, found: " + twoArgSites);
|
||||||
|
assertTrue(twoArgSites.stream().anyMatch(s -> s.contains("FleetMcp.java")),
|
||||||
|
"control failed: did not find the FleetMcp.java messages.poll(ticket, callerOwner) "
|
||||||
|
+ "site among: " + twoArgSites);
|
||||||
|
assertTrue(twoArgSites.stream().anyMatch(s -> s.contains("FleetApp.java")),
|
||||||
|
"control failed: did not find the FleetApp.java messages.poll(...) site among: "
|
||||||
|
+ twoArgSites);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code messages.poll(} anchor above only sees a {@code MessageService} reached through
|
||||||
|
* a variable, field, or parameter named {@code messages}. This asserts that every such
|
||||||
|
* declaration under {@code src/main/java} uses that name, so a differently named declaration
|
||||||
|
* -- invisible to the scan above -- fails loudly here instead of letting that scan pass on a
|
||||||
|
* call site it never looked at.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void everyMessageServiceDeclarationIsNamedMessages() throws IOException {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
int filesScanned = scanForDeclarationNames(PRODUCTION_SOURCE, names);
|
||||||
|
|
||||||
|
// CONTROL: the scan actually walked files -- a wrong root would otherwise report "every
|
||||||
|
// declaration is named messages" having looked at nothing.
|
||||||
|
assertTrue(filesScanned > 0, "control failed: the scan under " + PRODUCTION_SOURCE
|
||||||
|
+ " visited zero .java files -- the path is wrong, so the result below proves nothing");
|
||||||
|
|
||||||
|
// CONTROL: the declaration pattern actually finds real declarations. Zero means the
|
||||||
|
// pattern is broken, not that every MessageService variable, field, or parameter vanished.
|
||||||
|
assertTrue(names.size() > 0, "control failed: found zero MessageService declarations under "
|
||||||
|
+ PRODUCTION_SOURCE + " -- the declaration pattern is broken, update it before "
|
||||||
|
+ "trusting the naming check below");
|
||||||
|
|
||||||
|
List<String> other = names.stream().filter(n -> !n.equals("messages")).distinct().toList();
|
||||||
|
assertTrue(other.isEmpty(), "found a MessageService declaration not named \"messages\": "
|
||||||
|
+ other + " -- the messages.poll( scan above only looks for that name, so a call "
|
||||||
|
+ "through a differently named variable or field is invisible to it; either rename "
|
||||||
|
+ "the declaration or widen that scan's anchor to cover it");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int scanForPollCalls(Path root, List<String> violations, List<String> twoArgSites)
|
||||||
|
throws IOException {
|
||||||
|
List<Path> files = javaFiles(root);
|
||||||
|
for (Path file : files) {
|
||||||
|
scanFileForPollCalls(file, violations, twoArgSites);
|
||||||
|
}
|
||||||
|
return files.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int scanForDeclarationNames(Path root, List<String> names) throws IOException {
|
||||||
|
List<Path> files = javaFiles(root);
|
||||||
|
Pattern declaration = Pattern.compile("MessageService\\s+([A-Za-z_][A-Za-z0-9_]*)");
|
||||||
|
for (Path file : files) {
|
||||||
|
if (file.getFileName().toString().equals("MessageService.java")) {
|
||||||
|
continue; // the type's own declaration, not a caller holding a reference to it
|
||||||
|
}
|
||||||
|
String stripped = stripComments(Files.readString(file));
|
||||||
|
Matcher m = declaration.matcher(stripped);
|
||||||
|
while (m.find()) {
|
||||||
|
int j = m.end();
|
||||||
|
while (j < stripped.length() && Character.isWhitespace(stripped.charAt(j))) j++;
|
||||||
|
if (j < stripped.length() && stripped.charAt(j) == '(') {
|
||||||
|
continue; // a method named like the convention, e.g. "MessageService messages()"
|
||||||
|
}
|
||||||
|
names.add(m.group(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Path> javaFiles(Path root) throws IOException {
|
||||||
|
try (Stream<Path> paths = Files.walk(root)) {
|
||||||
|
return paths.filter(p -> p.toString().endsWith(".java")).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces {@code //} and {@code /* *}{@code /} comment text with nothing, leaving code,
|
||||||
|
* string/char literals and line breaks untouched -- so a comment that merely mentions
|
||||||
|
* {@code MessageService} in prose can never be read as a declaration.
|
||||||
|
*/
|
||||||
|
private static String stripComments(String source) {
|
||||||
|
StringBuilder out = new StringBuilder(source.length());
|
||||||
|
boolean inString = false;
|
||||||
|
boolean inChar = false;
|
||||||
|
int i = 0;
|
||||||
|
while (i < source.length()) {
|
||||||
|
char c = source.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
out.append(c);
|
||||||
|
if (c == '\\' && i + 1 < source.length()) { out.append(source.charAt(i + 1)); i += 2; continue; }
|
||||||
|
if (c == '"') inString = false;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inChar) {
|
||||||
|
out.append(c);
|
||||||
|
if (c == '\\' && i + 1 < source.length()) { out.append(source.charAt(i + 1)); i += 2; continue; }
|
||||||
|
if (c == '\'') inChar = false;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"') { inString = true; out.append(c); i++; continue; }
|
||||||
|
if (c == '\'') { inChar = true; out.append(c); i++; continue; }
|
||||||
|
if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '/') {
|
||||||
|
while (i < source.length() && source.charAt(i) != '\n') i++;
|
||||||
|
continue; // leaves the newline itself for the next iteration to append
|
||||||
|
}
|
||||||
|
if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '*') {
|
||||||
|
i += 2;
|
||||||
|
while (i < source.length() && !(source.charAt(i) == '*' && i + 1 < source.length()
|
||||||
|
&& source.charAt(i + 1) == '/')) {
|
||||||
|
if (source.charAt(i) == '\n') out.append('\n');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.append(c);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scanFileForPollCalls(Path file, List<String> violations, List<String> twoArgSites)
|
||||||
|
throws IOException {
|
||||||
|
String source = Files.readString(file);
|
||||||
|
String needle = "messages.poll(";
|
||||||
|
int from = 0;
|
||||||
|
int idx;
|
||||||
|
while ((idx = source.indexOf(needle, from)) >= 0) {
|
||||||
|
int argsStart = idx + needle.length();
|
||||||
|
String args = extractBalancedArgs(source, argsStart, file, idx);
|
||||||
|
int closeParenIndex = argsStart + args.length();
|
||||||
|
from = closeParenIndex + 1;
|
||||||
|
|
||||||
|
if (args.isBlank()) {
|
||||||
|
continue; // MessageService has no zero-argument poll() -- nothing to classify
|
||||||
|
}
|
||||||
|
String site = file + ":" + lineOf(source, idx);
|
||||||
|
if (topLevelCommaCount(args) == 0) {
|
||||||
|
violations.add(site + " -- messages.poll(" + args.trim() + ")");
|
||||||
|
} else {
|
||||||
|
twoArgSites.add(site);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text between {@code messages.poll(} and its matching close paren: balanced over nested
|
||||||
|
* calls, and never split by a paren or comma sitting inside a string or char literal.
|
||||||
|
*/
|
||||||
|
private static String extractBalancedArgs(String source, int start, Path file, int callIndex) {
|
||||||
|
int depth = 1;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean inChar = false;
|
||||||
|
int i = start;
|
||||||
|
while (i < source.length()) {
|
||||||
|
char c = source.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '"') inString = false;
|
||||||
|
} else if (inChar) {
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '\'') inChar = false;
|
||||||
|
} else if (c == '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (c == '\'') {
|
||||||
|
inChar = true;
|
||||||
|
} else if (c == '(') {
|
||||||
|
depth++;
|
||||||
|
} else if (c == ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) return source.substring(start, i);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"unbalanced parentheses scanning " + file + ":" + lineOf(source, callIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commas at paren/bracket/brace depth zero, skipping string and char literals -- the argument
|
||||||
|
* separators a human reader would see, not every comma character in the text.
|
||||||
|
*/
|
||||||
|
private static int topLevelCommaCount(String args) {
|
||||||
|
int depth = 0;
|
||||||
|
int commas = 0;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean inChar = false;
|
||||||
|
int i = 0;
|
||||||
|
while (i < args.length()) {
|
||||||
|
char c = args.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '"') inString = false;
|
||||||
|
} else if (inChar) {
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '\'') inChar = false;
|
||||||
|
} else if (c == '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (c == '\'') {
|
||||||
|
inChar = true;
|
||||||
|
} else if (c == '(' || c == '[' || c == '{') {
|
||||||
|
depth++;
|
||||||
|
} else if (c == ')' || c == ']' || c == '}') {
|
||||||
|
depth--;
|
||||||
|
} else if (c == ',' && depth == 0) {
|
||||||
|
commas++;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return commas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lineOf(String source, int index) {
|
||||||
|
int line = 1;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
if (source.charAt(i) == '\n') line++;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
|||||||
|
package dev.ltms.fleet.msg;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins that no file under {@code src/main/java} calls the fail-open
|
||||||
|
* {@link Rendezvous#open(String)} overload. That overload opens a forward waiter with no
|
||||||
|
* recorded {@link Rendezvous.Owner}, so the turn it opens can never be answered by anyone,
|
||||||
|
* not even the unnamed primary. Every production caller must go through
|
||||||
|
* {@link Rendezvous#open(String, Rendezvous.Owner)} and record an explicit owner.
|
||||||
|
*
|
||||||
|
* <p>This reads each file's own source text rather than reflecting on compiled bytecode, because
|
||||||
|
* the risk is a future one-word edit at a call site, not a missing overload.
|
||||||
|
*
|
||||||
|
* <p>The scan below finds a violation by its receiver, {@code rendezvous.open(}, classified by
|
||||||
|
* argument count. The one test method here points that exact scanner at a file known to hold
|
||||||
|
* many real one-argument calls before it ever looks at production, so a scanner that stops
|
||||||
|
* matching fails loudly on the known-positive case instead of leaving a clean production result
|
||||||
|
* looking like evidence it never produced.
|
||||||
|
*/
|
||||||
|
class RendezvousOpenUsageTest {
|
||||||
|
|
||||||
|
private static final Path PRODUCTION_SOURCE = Path.of("src/main/java");
|
||||||
|
private static final Path KNOWN_TEST_CALLER =
|
||||||
|
Path.of("src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pattern that cannot find a known one-argument {@code rendezvous.open(} call would also
|
||||||
|
* find none in production -- not because production is clean, but because the pattern does
|
||||||
|
* not match the text it is supposed to catch. That failure mode is exactly what let a
|
||||||
|
* {@code \b}-based regex read "no callers" under {@code git grep -E} when 81 real ones
|
||||||
|
* existed: {@code git grep} does not treat {@code \b} as a word boundary, so the pattern
|
||||||
|
* silently matched nothing anywhere, clean code and real calls alike. This test runs the
|
||||||
|
* known-positive check first, with the same scanning method the production check then
|
||||||
|
* depends on, so that mistake fails loudly here instead of reading as a clean result.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void noProductionFileCallsTheSingleArgumentOpenOverload() throws IOException {
|
||||||
|
List<String> knownCalls = new ArrayList<>();
|
||||||
|
int knownFilesScanned = scanForOneArgOpenCalls(KNOWN_TEST_CALLER, knownCalls);
|
||||||
|
|
||||||
|
// CONTROL: the scan actually walked files -- a wrong root would otherwise report "found
|
||||||
|
// nothing" having looked at nothing.
|
||||||
|
assertTrue(knownFilesScanned > 0, "control failed: the scan under " + KNOWN_TEST_CALLER
|
||||||
|
+ " visited zero .java files -- the path is wrong, so neither result below proves "
|
||||||
|
+ "anything");
|
||||||
|
|
||||||
|
// CONTROL: the scanner actually finds real one-argument rendezvous.open( calls when
|
||||||
|
// pointed at a file known to hold many. If this is not satisfied, the matching logic
|
||||||
|
// itself is broken, and the production result below is the scanner failing silently,
|
||||||
|
// not production code actually being clean.
|
||||||
|
assertTrue(knownCalls.size() >= 60, "control failed: the scanner found only "
|
||||||
|
+ knownCalls.size() + " one-argument rendezvous.open( call(s) in " + KNOWN_TEST_CALLER
|
||||||
|
+ ", which is known to hold many -- the matching logic itself is broken: " + knownCalls);
|
||||||
|
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
int filesScanned = scanForOneArgOpenCalls(PRODUCTION_SOURCE, violations);
|
||||||
|
|
||||||
|
// CONTROL: the scan actually walked files -- a wrong root would otherwise report "no
|
||||||
|
// violations found" having looked at nothing.
|
||||||
|
assertTrue(filesScanned > 0, "control failed: the scan under " + PRODUCTION_SOURCE
|
||||||
|
+ " visited zero .java files -- the path is wrong, so the absence of violations "
|
||||||
|
+ "below proves nothing");
|
||||||
|
|
||||||
|
assertTrue(violations.isEmpty(), "found a call to the fail-open Rendezvous.open(String) "
|
||||||
|
+ "overload, which opens a forward waiter with no recorded owner -- record an "
|
||||||
|
+ "explicit Rendezvous.Owner through open(String, Owner) instead: " + violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int scanForOneArgOpenCalls(Path root, List<String> sites) throws IOException {
|
||||||
|
List<Path> files = javaFiles(root);
|
||||||
|
for (Path file : files) {
|
||||||
|
scanFileForOpenCalls(file, sites);
|
||||||
|
}
|
||||||
|
return files.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Path> javaFiles(Path root) throws IOException {
|
||||||
|
try (Stream<Path> paths = Files.walk(root)) {
|
||||||
|
return paths.filter(p -> p.toString().endsWith(".java")).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scanFileForOpenCalls(Path file, List<String> sites) throws IOException {
|
||||||
|
String source = Files.readString(file);
|
||||||
|
String needle = "rendezvous.open(";
|
||||||
|
int from = 0;
|
||||||
|
int idx;
|
||||||
|
while ((idx = source.indexOf(needle, from)) >= 0) {
|
||||||
|
int argsStart = idx + needle.length();
|
||||||
|
String args = extractBalancedArgs(source, argsStart, file, idx);
|
||||||
|
int closeParenIndex = argsStart + args.length();
|
||||||
|
from = closeParenIndex + 1;
|
||||||
|
|
||||||
|
if (args.isBlank()) {
|
||||||
|
continue; // Rendezvous has no zero-argument open() -- a javadoc "rendezvous.open()" mention, not a call
|
||||||
|
}
|
||||||
|
if (topLevelCommaCount(args) == 0) {
|
||||||
|
sites.add(file + ":" + lineOf(source, idx) + " -- rendezvous.open(" + args.trim() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text between {@code rendezvous.open(} and its matching close paren: balanced over
|
||||||
|
* nested calls, and never split by a paren or comma sitting inside a string or char literal.
|
||||||
|
*/
|
||||||
|
private static String extractBalancedArgs(String source, int start, Path file, int callIndex) {
|
||||||
|
int depth = 1;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean inChar = false;
|
||||||
|
int i = start;
|
||||||
|
while (i < source.length()) {
|
||||||
|
char c = source.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '"') inString = false;
|
||||||
|
} else if (inChar) {
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '\'') inChar = false;
|
||||||
|
} else if (c == '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (c == '\'') {
|
||||||
|
inChar = true;
|
||||||
|
} else if (c == '(') {
|
||||||
|
depth++;
|
||||||
|
} else if (c == ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) return source.substring(start, i);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"unbalanced parentheses scanning " + file + ":" + lineOf(source, callIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commas at paren/bracket/brace depth zero, skipping string and char literals -- the argument
|
||||||
|
* separators a human reader would see, not every comma character in the text.
|
||||||
|
*/
|
||||||
|
private static int topLevelCommaCount(String args) {
|
||||||
|
int depth = 0;
|
||||||
|
int commas = 0;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean inChar = false;
|
||||||
|
int i = 0;
|
||||||
|
while (i < args.length()) {
|
||||||
|
char c = args.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '"') inString = false;
|
||||||
|
} else if (inChar) {
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '\'') inChar = false;
|
||||||
|
} else if (c == '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (c == '\'') {
|
||||||
|
inChar = true;
|
||||||
|
} else if (c == '(' || c == '[' || c == '{') {
|
||||||
|
depth++;
|
||||||
|
} else if (c == ')' || c == ']' || c == '}') {
|
||||||
|
depth--;
|
||||||
|
} else if (c == ',' && depth == 0) {
|
||||||
|
commas++;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return commas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lineOf(String source, int index) {
|
||||||
|
int line = 1;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
if (source.charAt(i) == '\n') line++;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,4 +132,127 @@ class RendezvousTest {
|
|||||||
assertNull(rendezvous.askSession(t.turnId()), "a closed ask is forgotten");
|
assertNull(rendezvous.askSession(t.turnId()), "a closed ask is forgotten");
|
||||||
assertFalse(rendezvous.answerAsk(t.turnId(), "late"), "a closed ask can no longer be answered");
|
assertFalse(rendezvous.answerAsk(t.turnId(), "late"), "a closed ask can no longer be answered");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #715: turn ownership ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openWithNoOwnerRecordsNoOwnerAndOpenWithAnOwnerRecordsIt() {
|
||||||
|
rendezvous.open(W);
|
||||||
|
assertNull(rendezvous.ownerOf(W), "the no-owner overload records no owner at all");
|
||||||
|
rendezvous.close(W, rendezvous.currentWaiter(W));
|
||||||
|
|
||||||
|
rendezvous.open(W, Rendezvous.Owner.of("term_lead"));
|
||||||
|
assertEquals(Rendezvous.Owner.of("term_lead"), rendezvous.ownerOf(W));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownerOfIsNullWhenNoWaiterIsOpen() {
|
||||||
|
assertNull(rendezvous.ownerOf(W), "no waiter open means no owner to report");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openAskStampsTheForwardWaitersOwnerOntoTheFreshTurnOnly() {
|
||||||
|
rendezvous.open(W, Rendezvous.Owner.of("term_lead"));
|
||||||
|
|
||||||
|
Rendezvous.AskTicket fresh = rendezvous.openAsk(W);
|
||||||
|
assertTrue(fresh.fresh());
|
||||||
|
assertEquals(Rendezvous.Owner.of("term_lead"), rendezvous.askOwner(fresh.turnId()),
|
||||||
|
"a freshly-opened ask copies the forward waiter's current owner");
|
||||||
|
|
||||||
|
Rendezvous.AskTicket coalesced = rendezvous.openAsk(W);
|
||||||
|
assertFalse(coalesced.fresh());
|
||||||
|
assertEquals(fresh.turnId(), coalesced.turnId());
|
||||||
|
assertEquals(Rendezvous.Owner.of("term_lead"), rendezvous.askOwner(coalesced.turnId()),
|
||||||
|
"a coalesced duplicate ask rides the fresh owner's turn, unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSecondAskAfterTheFirstClosesStampsWhateverOwnerIsOpenAtThatLaterMoment() {
|
||||||
|
rendezvous.open(W, Rendezvous.Owner.of("term_lead"));
|
||||||
|
Rendezvous.AskTicket first = rendezvous.openAsk(W);
|
||||||
|
rendezvous.closeAsk(first.turnId());
|
||||||
|
|
||||||
|
// The forward waiter is reopened under a different owner before the second ask — mirrors
|
||||||
|
// answer() reopening with the owner it already checked, which can differ turn to turn.
|
||||||
|
rendezvous.close(W, rendezvous.currentWaiter(W));
|
||||||
|
rendezvous.open(W, Rendezvous.Owner.of("term_other"));
|
||||||
|
|
||||||
|
Rendezvous.AskTicket second = rendezvous.openAsk(W);
|
||||||
|
assertTrue(second.fresh());
|
||||||
|
assertEquals(Rendezvous.Owner.of("term_other"), rendezvous.askOwner(second.turnId()),
|
||||||
|
"a freshly-opened ask always copies whatever owner is open right now, not a stale one");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void askOwnerIsNullForAnUnknownOrLapsedTurn() {
|
||||||
|
assertNull(rendezvous.askOwner("no-such#1"));
|
||||||
|
Rendezvous.AskTicket t = rendezvous.openAsk(W);
|
||||||
|
rendezvous.closeAsk(t.turnId());
|
||||||
|
assertNull(rendezvous.askOwner(t.turnId()), "a closed ask no longer reports an owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fleetd #729: per-boot nonce guards turnId against cross-instance reuse ────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoInstancesMintDisjointTurnIds() {
|
||||||
|
Rendezvous other = new Rendezvous();
|
||||||
|
Rendezvous.AskTicket fromThis = rendezvous.openAsk(W);
|
||||||
|
Rendezvous.AskTicket fromOther = other.openAsk(W);
|
||||||
|
assertNotEquals(fromThis.turnId(), fromOther.turnId(),
|
||||||
|
"each instance mints its own id space, so even a first ask from each must differ");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void foreignInstanceTurnIdDoesNotResolve() {
|
||||||
|
Rendezvous other = new Rendezvous();
|
||||||
|
|
||||||
|
// `other` must reach the same sequence number as `rendezvous` (two asks each, the first
|
||||||
|
// closed so the second mints fresh), or this test passes against an empty map instead of
|
||||||
|
// against a colliding id.
|
||||||
|
Rendezvous.AskTicket firstFromThis = rendezvous.openAsk(W);
|
||||||
|
rendezvous.closeAsk(firstFromThis.turnId());
|
||||||
|
Rendezvous.AskTicket secondFromThis = rendezvous.openAsk(W);
|
||||||
|
|
||||||
|
Rendezvous.AskTicket firstFromOther = other.openAsk(W);
|
||||||
|
other.closeAsk(firstFromOther.turnId());
|
||||||
|
other.openAsk(W);
|
||||||
|
|
||||||
|
// control: the id resolves in the instance that minted it, so a false below cannot be
|
||||||
|
// explained by broken plumbing — only by the turnId being foreign to `other`.
|
||||||
|
assertTrue(rendezvous.answerAsk(secondFromThis.turnId(), "answer from this instance"),
|
||||||
|
"the minting instance must still resolve its own turnId");
|
||||||
|
assertFalse(other.answerAsk(secondFromThis.turnId(), "answer from other instance"),
|
||||||
|
"a turnId minted by a different instance must not resolve here");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openAskStillCoalescesDuplicatesAndStillMintsDistinctIdsPerAsk() {
|
||||||
|
Rendezvous.AskTicket t1 = rendezvous.openAsk(W);
|
||||||
|
Rendezvous.AskTicket t2 = rendezvous.openAsk(W);
|
||||||
|
assertEquals(t1.turnId(), t2.turnId(),
|
||||||
|
"a second openAsk while one is open still coalesces onto the same turn");
|
||||||
|
assertFalse(t2.fresh(), "the coalesced ask is still reported as not fresh");
|
||||||
|
|
||||||
|
rendezvous.closeAsk(t1.turnId());
|
||||||
|
Rendezvous.AskTicket t3 = rendezvous.openAsk(W);
|
||||||
|
assertNotEquals(t1.turnId(), t3.turnId(), "two asks from the same session still get different turnIds");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownerPermitsIsFailClosedOnARecordAndThreeStatesAreDistinct() {
|
||||||
|
assertFalse(Rendezvous.Owner.permits(null, null),
|
||||||
|
"no owner on record refuses even the unnamed primary");
|
||||||
|
assertFalse(Rendezvous.Owner.permits(null, "worker:term_a"),
|
||||||
|
"no owner on record refuses a caller with an owner key too");
|
||||||
|
assertTrue(Rendezvous.Owner.permits(Rendezvous.Owner.UNNAMED_PRIMARY, null),
|
||||||
|
"the recorded unnamed primary matches a caller with a null owner key");
|
||||||
|
assertFalse(Rendezvous.Owner.permits(Rendezvous.Owner.UNNAMED_PRIMARY, "worker:term_a"),
|
||||||
|
"the recorded unnamed primary does not match another owner key");
|
||||||
|
assertTrue(Rendezvous.Owner.permits(Rendezvous.Owner.of("worker:term_a"), "worker:term_a"),
|
||||||
|
"an owner matches the same key");
|
||||||
|
assertFalse(Rendezvous.Owner.permits(Rendezvous.Owner.of("worker:term_a"), "worker:term_b"),
|
||||||
|
"an owner refuses a different key");
|
||||||
|
assertFalse(Rendezvous.Owner.permits(Rendezvous.Owner.of("worker:term_a"), null),
|
||||||
|
"a named owner refuses the unnamed primary");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package dev.ltms.fleet.msg;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
import dev.ltms.fleet.herdr.HerdrClient;
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
import dev.ltms.fleet.herdr.HerdrException;
|
import dev.ltms.fleet.herdr.HerdrException;
|
||||||
import dev.ltms.fleet.mcp.PrimaryRegistry;
|
import dev.ltms.fleet.mcp.PrimaryRegistry;
|
||||||
@@ -304,6 +305,40 @@ class ReplyPushLoopTest {
|
|||||||
+ "still live");
|
+ "still live");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fleetd #737 unit 3: the fallback nudgeTargetFor returns must be probed too --------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code resolveLiveLead} forgets a dead per-target binding and asks {@code PrimaryRegistry}
|
||||||
|
* again for a fallback. That fallback can be dead too — here PRIMARY, the pinned singleton, is
|
||||||
|
* affirmatively gone alongside DEAD_LEAD. A correct {@code resolveLiveLead} probes it exactly
|
||||||
|
* like the first lead and gives up for this tick rather than trust it unchecked.
|
||||||
|
*
|
||||||
|
* <p>This is checked through {@code onReplyQueued}/{@code isActive} rather than a send count:
|
||||||
|
* {@link ReplyPushLoop#onReplyQueued} only registers pending work and starts a schedule once
|
||||||
|
* {@code resolveLiveLead} returns a present value — a dead fallback that was trusted unprobed
|
||||||
|
* would already make this true, synchronously, with no tick or send needed to observe it. Pairs
|
||||||
|
* with {@link #aStaleLeadBindingFallsBackToTheLiveLeadInsteadOfNudgingADeadTerminal} as the
|
||||||
|
* positive control: same stale-DEAD_LEAD setup, but there the fallback (PRIMARY) is live and the
|
||||||
|
* nudge does fire — proving this test's "nothing happens" result comes from the fallback being
|
||||||
|
* dead, not from the assertion being unable to observe a nudge at all.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aDoublyDeadFallbackIsNeverTrustedAndStartsNoSchedule() {
|
||||||
|
registry.recordDelegation(WORKER, DEAD_LEAD);
|
||||||
|
|
||||||
|
var rec = new AllDeadHerdrClient(Set.of(DEAD_LEAD, PRIMARY));
|
||||||
|
agents = new AgentControl(rec);
|
||||||
|
inbox.publish(WORKER, "m1", "hello");
|
||||||
|
|
||||||
|
var loop = loop(1, 50);
|
||||||
|
loop.onReplyQueued(WORKER);
|
||||||
|
assertFalse(loop.isActive(),
|
||||||
|
"both the per-target binding and the fallback are dead, so resolveLiveLead must "
|
||||||
|
+ "return empty and onReplyQueued must never register pending work or start "
|
||||||
|
+ "a schedule — an unprobed fallback would start one here");
|
||||||
|
assertEquals(0, rec.promptTargets().size(), "nobody live was found, so nothing was ever sent");
|
||||||
|
}
|
||||||
|
|
||||||
// --- nudge format --------------------------------------------------------------------------
|
// --- nudge format --------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1045,6 +1080,76 @@ class ReplyPushLoopTest {
|
|||||||
"hitting the ticket reminder cap must count as exhausted");
|
"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 -------------------------------------------------------------------------------
|
// --- helpers -------------------------------------------------------------------------------
|
||||||
|
|
||||||
private ReplyPushLoop loop() {
|
private ReplyPushLoop loop() {
|
||||||
@@ -1063,6 +1168,15 @@ class ReplyPushLoopTest {
|
|||||||
return new AgentControl(new FakeHerdrClient(status));
|
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. */
|
/** Non-recording (single-threaded) fake — safe for decide() tests. */
|
||||||
private static final class FakeHerdrClient implements HerdrClient {
|
private static final class FakeHerdrClient implements HerdrClient {
|
||||||
private final String agentStatus;
|
private final String agentStatus;
|
||||||
@@ -1079,6 +1193,9 @@ class ReplyPushLoopTest {
|
|||||||
.put("terminal_id", PRIMARY)
|
.put("terminal_id", PRIMARY)
|
||||||
.put("agent_status", agentStatus));
|
.put("agent_status", agentStatus));
|
||||||
}
|
}
|
||||||
|
if ("agent.read".equals(method)) {
|
||||||
|
return emptyPromptBoxRead();
|
||||||
|
}
|
||||||
return MAPPER.createObjectNode();
|
return MAPPER.createObjectNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,6 +1225,9 @@ class ReplyPushLoopTest {
|
|||||||
calls.add(Map.entry(method, params));
|
calls.add(Map.entry(method, params));
|
||||||
sendLatch.countDown();
|
sendLatch.countDown();
|
||||||
}
|
}
|
||||||
|
if ("agent.read".equals(method)) {
|
||||||
|
return emptyPromptBoxRead();
|
||||||
|
}
|
||||||
return MAPPER.createObjectNode();
|
return MAPPER.createObjectNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,6 +1265,9 @@ class ReplyPushLoopTest {
|
|||||||
if ("agent.prompt".equals(method)) {
|
if ("agent.prompt".equals(method)) {
|
||||||
sendLatch.countDown();
|
sendLatch.countDown();
|
||||||
}
|
}
|
||||||
|
if ("agent.read".equals(method)) {
|
||||||
|
return emptyPromptBoxRead();
|
||||||
|
}
|
||||||
return MAPPER.createObjectNode();
|
return MAPPER.createObjectNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,6 +1305,9 @@ class ReplyPushLoopTest {
|
|||||||
calls.add(Map.entry(method, params));
|
calls.add(Map.entry(method, params));
|
||||||
sendLatch.countDown();
|
sendLatch.countDown();
|
||||||
}
|
}
|
||||||
|
if ("agent.read".equals(method)) {
|
||||||
|
return emptyPromptBoxRead();
|
||||||
|
}
|
||||||
return MAPPER.createObjectNode();
|
return MAPPER.createObjectNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1232,6 +1358,9 @@ class ReplyPushLoopTest {
|
|||||||
promptTargets.add(String.valueOf(p.get("target")));
|
promptTargets.add(String.valueOf(p.get("target")));
|
||||||
sendLatch.countDown();
|
sendLatch.countDown();
|
||||||
}
|
}
|
||||||
|
if ("agent.read".equals(method)) {
|
||||||
|
return emptyPromptBoxRead();
|
||||||
|
}
|
||||||
return MAPPER.createObjectNode();
|
return MAPPER.createObjectNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1248,6 +1377,55 @@ class ReplyPushLoopTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake herdr client for fleetd #737 unit 3: every terminal named in {@code deadTargets} reports
|
||||||
|
* {@code agent_not_found} from {@code agent.get} — unlike {@link DeadLeadHerdrClient}, which can
|
||||||
|
* only make one terminal dead, this can make a per-target binding AND its fallback dead in the
|
||||||
|
* same test. {@code agent.prompt} is recorded unconditionally (no liveness check of its own),
|
||||||
|
* so a test can tell "resolveLiveLead probed and correctly found nobody live" (no prompt call)
|
||||||
|
* apart from "resolveLiveLead trusted a dead fallback and sent into it anyway" (a prompt call to
|
||||||
|
* a terminal this fake has already declared gone).
|
||||||
|
*/
|
||||||
|
private static final class AllDeadHerdrClient implements HerdrClient {
|
||||||
|
private final Set<String> deadTargets;
|
||||||
|
private final List<String> promptTargets = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
|
||||||
|
AllDeadHerdrClient(Set<String> deadTargets) {
|
||||||
|
this.deadTargets = deadTargets;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public JsonNode call(String method, Object params) {
|
||||||
|
Map<String, Object> p = params instanceof Map ? (Map<String, Object>) params : Map.of();
|
||||||
|
if ("agent.get".equals(method)) {
|
||||||
|
String target = String.valueOf(p.get("target"));
|
||||||
|
if (deadTargets.contains(target)) {
|
||||||
|
throw new HerdrException("no such agent: " + target, "agent_not_found", null);
|
||||||
|
}
|
||||||
|
return MAPPER.createObjectNode()
|
||||||
|
.set("agent", MAPPER.createObjectNode()
|
||||||
|
.put("terminal_id", target)
|
||||||
|
.put("agent_status", "idle"));
|
||||||
|
}
|
||||||
|
if ("agent.prompt".equals(method)) {
|
||||||
|
promptTargets.add(String.valueOf(p.get("target")));
|
||||||
|
}
|
||||||
|
if ("agent.read".equals(method)) {
|
||||||
|
return emptyPromptBoxRead();
|
||||||
|
}
|
||||||
|
return MAPPER.createObjectNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> promptTargets() {
|
||||||
|
return List.copyOf(promptTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fake herdr client for fleetd #368 review: {@code flakyTarget}'s FIRST {@code agent.get} call
|
* Fake herdr client for fleetd #368 review: {@code flakyTarget}'s FIRST {@code agent.get} call
|
||||||
* fails with a transient, non-{@code agent_not_found} {@code HerdrException} — a transport-level
|
* fails with a transient, non-{@code agent_not_found} {@code HerdrException} — a transport-level
|
||||||
@@ -1283,6 +1461,9 @@ class ReplyPushLoopTest {
|
|||||||
promptTargets.add(String.valueOf(p.get("target")));
|
promptTargets.add(String.valueOf(p.get("target")));
|
||||||
sendLatch.countDown();
|
sendLatch.countDown();
|
||||||
}
|
}
|
||||||
|
if ("agent.read".equals(method)) {
|
||||||
|
return emptyPromptBoxRead();
|
||||||
|
}
|
||||||
return MAPPER.createObjectNode();
|
return MAPPER.createObjectNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1294,4 +1475,60 @@ class ReplyPushLoopTest {
|
|||||||
public void close() {
|
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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package dev.ltms.fleet.rest;
|
package dev.ltms.fleet.rest;
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.Level;
|
||||||
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.ltms.fleet.auth.CallerResolver;
|
import dev.ltms.fleet.auth.CallerResolver;
|
||||||
import dev.ltms.fleet.auth.Authz;
|
import dev.ltms.fleet.auth.Authz;
|
||||||
import dev.ltms.fleet.auth.MemberRegistry;
|
import dev.ltms.fleet.auth.MemberRegistry;
|
||||||
|
import dev.ltms.fleet.auth.Principal;
|
||||||
|
import dev.ltms.fleet.auth.Role;
|
||||||
import dev.ltms.fleet.config.FleetConfig;
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
import dev.ltms.fleet.guard.SubscriptionGuard;
|
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
@@ -15,9 +21,11 @@ import dev.ltms.fleet.metrics.FleetMetrics;
|
|||||||
import dev.ltms.fleet.metrics.Metrics;
|
import dev.ltms.fleet.metrics.Metrics;
|
||||||
import dev.ltms.fleet.msg.MessageService;
|
import dev.ltms.fleet.msg.MessageService;
|
||||||
import dev.ltms.fleet.msg.Rendezvous;
|
import dev.ltms.fleet.msg.Rendezvous;
|
||||||
|
import dev.ltms.fleet.peer.MemberRole;
|
||||||
import dev.ltms.fleet.session.FakeWorktrees;
|
import dev.ltms.fleet.session.FakeWorktrees;
|
||||||
import dev.ltms.fleet.session.SessionManager;
|
import dev.ltms.fleet.session.SessionManager;
|
||||||
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
||||||
|
import dev.ltms.fleet.testing.CapturedLog;
|
||||||
import io.javalin.Javalin;
|
import io.javalin.Javalin;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -28,10 +36,15 @@ import java.net.http.HttpRequest;
|
|||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -78,8 +91,13 @@ class FleetAppAuthTest {
|
|||||||
MessageService messages = new MessageService(agents, injector, new Rendezvous());
|
MessageService messages = new MessageService(agents, injector, new Rendezvous());
|
||||||
|
|
||||||
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
|
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,
|
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());
|
metrics = FleetMetrics.create(sessions, new dev.ltms.fleet.msg.InMemoryReplyInbox());
|
||||||
|
|
||||||
app = new FleetApp(herdr, workers, sessions, messages, sessions.asPresence(), null,
|
app = new FleetApp(herdr, workers, sessions, messages, sessions.asPresence(), null,
|
||||||
@@ -122,12 +140,503 @@ class FleetAppAuthTest {
|
|||||||
assertEquals(Authz.Action.DRAIN, FleetApp.routeAction("GET /sessions/{id}/replies"));
|
assertEquals(Authz.Action.DRAIN, FleetApp.routeAction("GET /sessions/{id}/replies"));
|
||||||
assertEquals(Authz.Action.ASK, FleetApp.routeAction("POST /sessions/{id}/ask"));
|
assertEquals(Authz.Action.ASK, FleetApp.routeAction("POST /sessions/{id}/ask"));
|
||||||
for (String route : Set.of("GET /sessions", "GET /agents", "GET /members", "GET /profiles",
|
for (String route : Set.of("GET /sessions", "GET /agents", "GET /members", "GET /profiles",
|
||||||
"GET /member-credentials", "GET /sessions/{id}/status", "GET /tasks/{ticket}")) {
|
"GET /member-credentials")) {
|
||||||
assertEquals(Authz.Action.READ, FleetApp.routeAction(route), route);
|
assertEquals(Authz.Action.READ, FleetApp.routeAction(route), route);
|
||||||
}
|
}
|
||||||
|
for (String route : Set.of("GET /sessions/{id}/status", "GET /tasks/{ticket}")) {
|
||||||
|
assertEquals(Authz.Action.TASK_READ, FleetApp.routeAction(route), route);
|
||||||
|
}
|
||||||
assertThrows(IllegalArgumentException.class, () -> FleetApp.routeAction("GET /healthz"));
|
assertThrows(IllegalArgumentException.class, () -> FleetApp.routeAction("GET /healthz"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GET /tasks/{ticket} must not be the no-check overload ----------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code GET /tasks/{ticket}} must resolve its caller the same way {@code allow(...)} does
|
||||||
|
* and thread that owner key into {@link MessageService#poll(String, String)}, not the
|
||||||
|
* no-check overload that ignores who is asking.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theTaskStatusRouteActuallyThreadsTheCallersOwnerKeyIntoPoll() throws Exception {
|
||||||
|
String source = Files.readString(REST_SOURCE);
|
||||||
|
|
||||||
|
int start = source.indexOf("private void taskStatus(Context ctx) {");
|
||||||
|
assertTrue(start >= 0, "could not find taskStatus in " + REST_SOURCE
|
||||||
|
+ " -- the scrape has stopped matching, fix the anchor before trusting this test");
|
||||||
|
int end = source.indexOf("private static void herdrError(Context ctx, HerdrException e) {", start);
|
||||||
|
assertTrue(end > start, "could not find the method declared after taskStatus to bound the scrape");
|
||||||
|
String handlerBlock = source.substring(start, end);
|
||||||
|
|
||||||
|
// CONTROL: the block we scraped really does call messages.poll(...) -- if this fails, the
|
||||||
|
// anchors above moved and the assertions below would otherwise pass on nothing.
|
||||||
|
assertTrue(handlerBlock.contains("messages.poll("),
|
||||||
|
"control failed: the scraped taskStatus block contains no messages.poll( call at all "
|
||||||
|
+ "-- the anchors have drifted, this test is not testing what it claims to");
|
||||||
|
|
||||||
|
assertTrue(handlerBlock.contains("caller.ownerKey()"),
|
||||||
|
"the taskStatus route must thread the resolved caller's owner key into messages.poll(...), "
|
||||||
|
+ "not the no-check overload -- block: " + handlerBlock);
|
||||||
|
assertTrue(handlerBlock.contains("ctx.attribute(CALLER)"),
|
||||||
|
"the taskStatus route must resolve its caller the same way allow(...) does, not via a "
|
||||||
|
+ "second, separate resolution path -- block: " + handlerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code GET /sessions/{id}/status} must resolve its caller the same way {@code allow(...)}
|
||||||
|
* does and thread that owner key into {@link MessageService#pendingAsk(String, String)}, not
|
||||||
|
* the no-check overload that ignores who is asking.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theSessionStatusRouteActuallyThreadsTheCallersOwnerKeyIntoPendingAsk() throws Exception {
|
||||||
|
String source = Files.readString(REST_SOURCE);
|
||||||
|
|
||||||
|
int start = source.indexOf("private void sessionStatus(Context ctx) {");
|
||||||
|
assertTrue(start >= 0, "could not find sessionStatus in " + REST_SOURCE
|
||||||
|
+ " -- the scrape has stopped matching, fix the anchor before trusting this test");
|
||||||
|
int end = source.indexOf("private void taskStatus(Context ctx) {", start);
|
||||||
|
assertTrue(end > start, "could not find the method declared after sessionStatus to bound the scrape");
|
||||||
|
String handlerBlock = source.substring(start, end);
|
||||||
|
|
||||||
|
// CONTROL: the block we scraped really does call messages.pendingAsk(...) -- if this fails,
|
||||||
|
// the anchors above moved and the assertions below would otherwise pass on nothing.
|
||||||
|
assertTrue(handlerBlock.contains("messages.pendingAsk("),
|
||||||
|
"control failed: the scraped sessionStatus block contains no messages.pendingAsk( "
|
||||||
|
+ "call at all -- the anchors have drifted, this test is not testing what it claims to");
|
||||||
|
|
||||||
|
assertTrue(handlerBlock.contains("caller.ownerKey()"),
|
||||||
|
"the sessionStatus route must thread the resolved caller's owner key into "
|
||||||
|
+ "messages.pendingAsk(...), not the no-check overload -- block: " + handlerBlock);
|
||||||
|
assertTrue(handlerBlock.contains("ctx.attribute(CALLER)"),
|
||||||
|
"the sessionStatus route must resolve its caller the same way allow(...) does, not via "
|
||||||
|
+ "a second, separate resolution path -- block: " + handlerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code GET /tasks/{ticket}} refuses a worker whose terminal did not create the ticket, and
|
||||||
|
* refuses an unnamed primary just the same: a named worker's ticket is not anyone else's to
|
||||||
|
* read, caller rank included. The ticket is minted directly on the shared
|
||||||
|
* {@link MessageService}, the same way {@code MessageServiceTest} drives
|
||||||
|
* {@link MessageService#poll(String, String)}, so this exercises only the REST poll route's
|
||||||
|
* own handling of the ownership already recorded on the ticket.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void restPollRefusesADifferentWorkerAndAnUnnamedPrimaryOnANamedWorkersTicket() throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
Injector injector = new Injector(agents);
|
||||||
|
MessageService messages = new MessageService(agents, injector, new Rendezvous());
|
||||||
|
|
||||||
|
Javalin creatorApp = startOnSharedService(messages, herdr, FakeHerdr.WORKER_PID); // -> term_a
|
||||||
|
Javalin otherWorkerApp = startOnSharedService(messages, herdr, 9001L); // -> term_shell
|
||||||
|
Javalin primaryApp = startOnSharedService(messages, herdr, 999_999L); // no pane -> primary
|
||||||
|
try {
|
||||||
|
String ticket = messages.sendAsync("term_a", "long task", null,
|
||||||
|
Principal.worker("term_a", FakeHerdr.WORKER_PID));
|
||||||
|
|
||||||
|
HttpResponse<String> refused = send(otherWorkerApp.port(), "GET", "/tasks/" + ticket, null, null);
|
||||||
|
assertEquals(200, refused.statusCode());
|
||||||
|
assertTrue(refused.body().contains("forbidden"),
|
||||||
|
"a different worker's terminal must be refused, not shown the ticket: " + refused.body());
|
||||||
|
assertFalse(refused.body().contains("\"reply\""),
|
||||||
|
"a refusal must never carry reply text: " + refused.body());
|
||||||
|
|
||||||
|
HttpResponse<String> own = send(creatorApp.port(), "GET", "/tasks/" + ticket, null, null);
|
||||||
|
assertEquals(200, own.statusCode());
|
||||||
|
assertFalse(own.body().contains("forbidden"),
|
||||||
|
"the creating worker must read its own ticket: " + own.body());
|
||||||
|
|
||||||
|
HttpResponse<String> primary = send(primaryApp.port(), "GET", "/tasks/" + ticket, null, null);
|
||||||
|
assertEquals(200, primary.statusCode());
|
||||||
|
assertTrue(primary.body().contains("forbidden"),
|
||||||
|
"an unnamed primary must not read a ticket a named worker created: " + primary.body());
|
||||||
|
assertFalse(primary.body().contains("\"reply\""),
|
||||||
|
"a refusal must never carry reply text: " + primary.body());
|
||||||
|
} finally {
|
||||||
|
creatorApp.stop();
|
||||||
|
otherWorkerApp.stop();
|
||||||
|
primaryApp.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positive control for
|
||||||
|
* {@link #restPollRefusesADifferentWorkerAndAnUnnamedPrimaryOnANamedWorkersTicket}: without
|
||||||
|
* this, that test's refusal would pass just as well if the route refused every caller. Here
|
||||||
|
* the ticket's creator is itself an unnamed primary, so another unnamed primary reading it
|
||||||
|
* over REST must still succeed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void restPollAllowsAnUnnamedPrimaryItsOwnTicket() throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
Injector injector = new Injector(agents);
|
||||||
|
MessageService messages = new MessageService(agents, injector, new Rendezvous());
|
||||||
|
|
||||||
|
Javalin primaryApp = startOnSharedService(messages, herdr, 999_999L); // no pane -> primary
|
||||||
|
try {
|
||||||
|
String ticket = messages.sendAsync("term_a", "long task", null, Principal.primary(FakeHerdr.WORKER_PID));
|
||||||
|
|
||||||
|
HttpResponse<String> own = send(primaryApp.port(), "GET", "/tasks/" + ticket, null, null);
|
||||||
|
assertEquals(200, own.statusCode());
|
||||||
|
assertFalse(own.body().contains("forbidden"),
|
||||||
|
"an unnamed primary must read a ticket another unnamed primary created: " + own.body());
|
||||||
|
} finally {
|
||||||
|
primaryApp.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code POST /sessions/{id}/message} with {@code wait:false} must record the creating
|
||||||
|
* caller's own terminal on the ticket it returns, so that caller can still poll its own
|
||||||
|
* ticket over REST, while a different terminal is refused.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void restSendAsyncRecordsTheCreatingCallersTerminalSoItCanStillPollItsOwnTicket() throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
Injector injector = new Injector(agents);
|
||||||
|
MessageService messages = new MessageService(agents, injector, new Rendezvous());
|
||||||
|
|
||||||
|
Javalin leadApp = startOnSharedService(messages, herdr, FakeHerdr.WORKER_PID, Map.of("term_a", "lead-x"));
|
||||||
|
Javalin otherWorkerApp = startOnSharedService(messages, herdr, 9001L); // -> term_shell
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
HttpResponse<String> created = send(leadApp.port(), "POST", "/sessions/term_a/message",
|
||||||
|
"{\"content\":\"long task\",\"wait\":false}", null);
|
||||||
|
assertEquals(202, created.statusCode(), created.body());
|
||||||
|
String ticket = mapper.readTree(created.body()).path("ticket").asText(null);
|
||||||
|
assertNotNull(ticket, "the accepted response carried no ticket: " + created.body());
|
||||||
|
|
||||||
|
HttpResponse<String> own = send(leadApp.port(), "GET", "/tasks/" + ticket, null, null);
|
||||||
|
assertEquals(200, own.statusCode());
|
||||||
|
assertFalse(own.body().contains("forbidden"),
|
||||||
|
"the session that created the ticket over REST must be able to poll it: " + own.body());
|
||||||
|
|
||||||
|
HttpResponse<String> refused = send(otherWorkerApp.port(), "GET", "/tasks/" + ticket, null, null);
|
||||||
|
assertEquals(200, refused.statusCode());
|
||||||
|
assertTrue(refused.body().contains("forbidden: this ticket was created by a different session"),
|
||||||
|
"a different terminal must still be refused with the ownership detail, not some "
|
||||||
|
+ "other rejection: " + refused.body());
|
||||||
|
} finally {
|
||||||
|
leadApp.stop();
|
||||||
|
otherWorkerApp.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code GET /sessions/{id}/status} shows a worker's pending {@code fleet_ask} question, its
|
||||||
|
* {@code turnId} and its ticket only to the caller whose owner key created that delegation. An
|
||||||
|
* unnamed primary is held to the same rule: its owner key is {@code null}, which here does not
|
||||||
|
* match the named worker that created the delegation, so it sees none of the pending-ask
|
||||||
|
* fields either — the same as any other non-creating caller.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void restStatusGatesThePendingAskFieldsByTheDelegationsCreatorOwner() throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
Injector injector = new Injector(agents);
|
||||||
|
Rendezvous rendezvous = new Rendezvous();
|
||||||
|
MessageService messages = new MessageService(agents, injector, rendezvous);
|
||||||
|
|
||||||
|
Javalin creatorApp = startOnSharedService(messages, herdr, FakeHerdr.WORKER_PID); // -> term_a
|
||||||
|
Javalin otherWorkerApp = startOnSharedService(messages, herdr, 9001L); // -> term_shell
|
||||||
|
Javalin primaryApp = startOnSharedService(messages, herdr, 999_999L); // no pane -> primary
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
String ticket = messages.sendAsync("term_target", "task that asks", null,
|
||||||
|
Principal.worker("term_a", FakeHerdr.WORKER_PID));
|
||||||
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting("term_target") && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.isWaiting("term_target"), "sendAsync should have opened its rendezvous waiter");
|
||||||
|
|
||||||
|
CompletableFuture<MessageService.AskResult> ask = CompletableFuture.supplyAsync(
|
||||||
|
() -> messages.ask("term_target", "which config file?", 5000));
|
||||||
|
|
||||||
|
MessageService.TaskView asking;
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
do {
|
||||||
|
// the no-check overload: this is test plumbing waiting for ASKING, not the gate under test
|
||||||
|
asking = messages.poll(ticket);
|
||||||
|
Thread.sleep(5);
|
||||||
|
} while (asking.phase() != MessageService.Phase.ASKING && System.currentTimeMillis() < deadline);
|
||||||
|
assertEquals(MessageService.Phase.ASKING, asking.phase());
|
||||||
|
String turnId = asking.turnId();
|
||||||
|
|
||||||
|
JsonNode other = mapper.readTree(
|
||||||
|
send(otherWorkerApp.port(), "GET", "/sessions/term_target/status", null, null).body());
|
||||||
|
assertEquals("idle", other.get("status").asText(), "the base status must still be shown");
|
||||||
|
assertFalse(other.has("question"), "a non-creating caller must not see the question: " + other);
|
||||||
|
assertFalse(other.has("turnId"), "a non-creating caller must not see the turnId: " + other);
|
||||||
|
assertFalse(other.has("ticket"), "a non-creating caller must not see the ticket: " + other);
|
||||||
|
|
||||||
|
JsonNode own = mapper.readTree(
|
||||||
|
send(creatorApp.port(), "GET", "/sessions/term_target/status", null, null).body());
|
||||||
|
assertEquals("which config file?", own.get("question").asText(), "the creator must see the question");
|
||||||
|
assertEquals(turnId, own.get("turnId").asText(), "the creator must see the turnId");
|
||||||
|
assertEquals(ticket, own.get("ticket").asText(), "the creator must see the ticket");
|
||||||
|
|
||||||
|
JsonNode primary = mapper.readTree(
|
||||||
|
send(primaryApp.port(), "GET", "/sessions/term_target/status", null, null).body());
|
||||||
|
assertEquals("idle", primary.get("status").asText(), "the base status must still be shown");
|
||||||
|
assertFalse(primary.has("question"),
|
||||||
|
"an unnamed primary must not see a question on a delegation a named worker created: " + primary);
|
||||||
|
assertFalse(primary.has("turnId"), "a non-creating unnamed primary must not see the turnId: " + primary);
|
||||||
|
assertFalse(primary.has("ticket"), "a non-creating unnamed primary must not see the ticket: " + primary);
|
||||||
|
|
||||||
|
// Clean up the still-open ask so the background thread does not linger past the test.
|
||||||
|
CompletableFuture<MessageService.Reply> answer = CompletableFuture.supplyAsync(
|
||||||
|
() -> messages.answer(turnId, "config.yaml", 5000, "worker:term_a"));
|
||||||
|
assertEquals("config.yaml", ask.get(5, TimeUnit.SECONDS).answer());
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting("term_target") && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.resolve("term_target", "done"));
|
||||||
|
answer.get(5, TimeUnit.SECONDS);
|
||||||
|
} finally {
|
||||||
|
creatorApp.stop();
|
||||||
|
otherWorkerApp.stop();
|
||||||
|
primaryApp.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code POST /sessions/{id}/message} with a {@code turnId} refuses a caller whose terminal
|
||||||
|
* did not open the turn, even when that caller otherwise holds ANSWER rights, and leaves the
|
||||||
|
* turn open for the real owner to resolve. Covers the REST adapter's ANSWER gate for an
|
||||||
|
* async-send ({@code wait:false}) delegation.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void restAnswerIsRefusedForADifferentCallerOnAnAsyncSendDelegationButTheRealOwnerSucceeds() throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
Injector injector = new Injector(agents);
|
||||||
|
Rendezvous rendezvous = new Rendezvous();
|
||||||
|
MessageService messages = new MessageService(agents, injector, rendezvous);
|
||||||
|
|
||||||
|
Javalin ownerApp = startOnSharedService(messages, herdr, FakeHerdr.WORKER_PID, Map.of("term_a", "lead-owner"));
|
||||||
|
Javalin attackerApp = startOnSharedService(messages, herdr, 9001L, Map.of("term_shell", "lead-attacker"));
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
HttpResponse<String> created = send(ownerApp.port(), "POST", "/sessions/term_target/message",
|
||||||
|
"{\"content\":\"long task\",\"wait\":false}", null);
|
||||||
|
assertEquals(202, created.statusCode(), created.body());
|
||||||
|
String ticket = mapper.readTree(created.body()).path("ticket").asText(null);
|
||||||
|
assertNotNull(ticket, "the accepted response carried no ticket: " + created.body());
|
||||||
|
|
||||||
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting("term_target") && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.isWaiting("term_target"), "the async send should have opened its rendezvous waiter");
|
||||||
|
|
||||||
|
CompletableFuture<MessageService.AskResult> ask = CompletableFuture.supplyAsync(
|
||||||
|
() -> messages.ask("term_target", "which config file?", 5000));
|
||||||
|
|
||||||
|
MessageService.TaskView asking;
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
do {
|
||||||
|
// the no-check overload: this is test plumbing waiting for ASKING, not the gate under test
|
||||||
|
asking = messages.poll(ticket);
|
||||||
|
Thread.sleep(5);
|
||||||
|
} while (asking.phase() != MessageService.Phase.ASKING && System.currentTimeMillis() < deadline);
|
||||||
|
assertEquals(MessageService.Phase.ASKING, asking.phase());
|
||||||
|
String turnId = asking.turnId();
|
||||||
|
|
||||||
|
HttpResponse<String> hijacked = send(attackerApp.port(), "POST", "/sessions/term_target/message",
|
||||||
|
"{\"content\":\"hijack\",\"turnId\":\"" + turnId + "\"}", null);
|
||||||
|
assertEquals(403, hijacked.statusCode(), hijacked.body());
|
||||||
|
assertTrue(hijacked.body().contains("not_turn_owner"),
|
||||||
|
"a different caller's answer must be refused as not_turn_owner: " + hijacked.body());
|
||||||
|
assertEquals("term_target", rendezvous.askSession(turnId),
|
||||||
|
"a refused answer must leave the ask turn open");
|
||||||
|
|
||||||
|
CompletableFuture<HttpResponse<String>> owned = CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
return send(ownerApp.port(), "POST", "/sessions/term_target/message",
|
||||||
|
"{\"content\":\"config.yaml\",\"turnId\":\"" + turnId + "\"}", null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertEquals("config.yaml", ask.get(5, TimeUnit.SECONDS).answer());
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting("term_target") && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.resolve("term_target", "done"));
|
||||||
|
HttpResponse<String> ownedResponse = owned.get(5, TimeUnit.SECONDS);
|
||||||
|
assertEquals(200, ownedResponse.statusCode(), ownedResponse.body());
|
||||||
|
assertEquals("done", mapper.readTree(ownedResponse.body()).path("reply").asText(null),
|
||||||
|
"the real owner's answer must resolve the worker's turn");
|
||||||
|
} finally {
|
||||||
|
ownerApp.stop();
|
||||||
|
attackerApp.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but the delegation is a blocking send ({@code wait:true}) instead of a ticket —
|
||||||
|
* the owning caller's own HTTP request is the one that surfaces the worker's question and
|
||||||
|
* later carries the real answer. Covers the REST adapter's ANSWER gate for a blocking-send
|
||||||
|
* delegation.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void restAnswerIsRefusedForADifferentCallerOnABlockingSendDelegationButTheRealOwnerSucceeds() throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
Injector injector = new Injector(agents);
|
||||||
|
Rendezvous rendezvous = new Rendezvous();
|
||||||
|
MessageService messages = new MessageService(agents, injector, rendezvous);
|
||||||
|
|
||||||
|
Javalin ownerApp = startOnSharedService(messages, herdr, FakeHerdr.WORKER_PID, Map.of("term_a", "lead-owner"));
|
||||||
|
Javalin attackerApp = startOnSharedService(messages, herdr, 9001L, Map.of("term_shell", "lead-attacker"));
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
CompletableFuture<HttpResponse<String>> blocking = CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
return send(ownerApp.port(), "POST", "/sessions/term_target/message",
|
||||||
|
"{\"content\":\"long task\",\"wait\":true,\"timeoutMs\":5000}", null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting("term_target") && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.isWaiting("term_target"), "the blocking send should have opened its rendezvous waiter");
|
||||||
|
|
||||||
|
CompletableFuture<MessageService.AskResult> ask = CompletableFuture.supplyAsync(
|
||||||
|
() -> messages.ask("term_target", "which config file?", 5000));
|
||||||
|
|
||||||
|
HttpResponse<String> questionResponse = blocking.get(5, TimeUnit.SECONDS);
|
||||||
|
assertEquals(202, questionResponse.statusCode(), questionResponse.body());
|
||||||
|
JsonNode question = mapper.readTree(questionResponse.body());
|
||||||
|
assertEquals("question", question.get("status").asText());
|
||||||
|
String turnId = question.get("turnId").asText();
|
||||||
|
|
||||||
|
HttpResponse<String> hijacked = send(attackerApp.port(), "POST", "/sessions/term_target/message",
|
||||||
|
"{\"content\":\"hijack\",\"turnId\":\"" + turnId + "\"}", null);
|
||||||
|
assertEquals(403, hijacked.statusCode(), hijacked.body());
|
||||||
|
assertTrue(hijacked.body().contains("not_turn_owner"),
|
||||||
|
"a different caller's answer must be refused as not_turn_owner: " + hijacked.body());
|
||||||
|
assertEquals("term_target", rendezvous.askSession(turnId),
|
||||||
|
"a refused answer must leave the ask turn open");
|
||||||
|
|
||||||
|
CompletableFuture<HttpResponse<String>> owned = CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
return send(ownerApp.port(), "POST", "/sessions/term_target/message",
|
||||||
|
"{\"content\":\"config.yaml\",\"turnId\":\"" + turnId + "\"}", null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertEquals("config.yaml", ask.get(5, TimeUnit.SECONDS).answer());
|
||||||
|
deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (!rendezvous.isWaiting("term_target") && System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
assertTrue(rendezvous.resolve("term_target", "done"));
|
||||||
|
HttpResponse<String> ownedResponse = owned.get(5, TimeUnit.SECONDS);
|
||||||
|
assertEquals(200, ownedResponse.statusCode(), ownedResponse.body());
|
||||||
|
assertEquals("done", mapper.readTree(ownedResponse.body()).path("reply").asText(null),
|
||||||
|
"the real owner's answer must resolve the worker's turn");
|
||||||
|
} finally {
|
||||||
|
ownerApp.stop();
|
||||||
|
attackerApp.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #start}, but shares {@code messages} and {@code herdr} across several app
|
||||||
|
* instances bound to different pids, each returned as its own started {@link Javalin} rather
|
||||||
|
* than through the shared {@code app} field, so several differently-resolved callers can
|
||||||
|
* poll the same ticket.
|
||||||
|
*/
|
||||||
|
private Javalin startOnSharedService(MessageService messages, FakeHerdr herdr, long pid) {
|
||||||
|
return startOnSharedService(messages, herdr, pid, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #startOnSharedService(MessageService, FakeHerdr, long)}, but {@code leadTerminals}
|
||||||
|
* resolves the given pid's terminal to a named lead (a caller with SEND permission) instead of
|
||||||
|
* a plain worker, for a test that needs a terminal-bearing caller able to create a ticket.
|
||||||
|
*
|
||||||
|
* <p>Every connecting pane not already claimed by {@code leadTerminals} is wired into the live
|
||||||
|
* roster as a spawned worker, so a caller's resolved role matches what its own test expects:
|
||||||
|
* a {@link Role#WORKER}, never the unconfigured-pane {@link Role#OBSERVER} floor a roster-less
|
||||||
|
* resolver would otherwise fall to.
|
||||||
|
*/
|
||||||
|
private Javalin startOnSharedService(MessageService messages, FakeHerdr herdr, long pid,
|
||||||
|
Map<String, String> leadTerminals) {
|
||||||
|
FleetConfig.Profile wcfg = new FleetConfig.Profile(
|
||||||
|
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null,
|
||||||
|
"tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(
|
||||||
|
agents, new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")),
|
||||||
|
Map.of(wcfg.profile(), wcfg), wcfg.profile(),
|
||||||
|
k -> "FLEETD_WORKER_TOKEN".equals(k) ? "tok-abc" : null);
|
||||||
|
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
|
||||||
|
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
|
||||||
|
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
|
||||||
|
() -> leadTerminals, new MemberRegistry(null),
|
||||||
|
t -> leadTerminals.containsKey(t) ? null : MemberRole.DEV, Map::of);
|
||||||
|
Metrics appMetrics = FleetMetrics.create(sessions, new dev.ltms.fleet.msg.InMemoryReplyInbox());
|
||||||
|
|
||||||
|
return new FleetApp(herdr, workers, sessions, messages, sessions.asPresence(), null,
|
||||||
|
callers, appMetrics).build().start("127.0.0.1", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit A: {@code POST /sessions/{id}/message} is two call shapes behind one route,
|
||||||
|
* mirroring {@code fleet_send}'s MCP-side split into {@link Authz.Action#SEND} and {@link
|
||||||
|
* Authz.Action#ANSWER} ({@code FleetMcp#sendAction}). The route never carries a {@code coordId}
|
||||||
|
* shape — that peer-lead route is MCP-only — so only these two apply here.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theMessageRouteIsASendWithNoTurnIdAndAnAnswerWithOne() {
|
||||||
|
assertEquals(Authz.Action.SEND, FleetApp.routeAction("POST /sessions/{id}/message", null));
|
||||||
|
assertEquals(Authz.Action.SEND, FleetApp.routeAction("POST /sessions/{id}/message", " "));
|
||||||
|
assertEquals(Authz.Action.ANSWER, FleetApp.routeAction("POST /sessions/{id}/message", "turn-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #689: {@code answerGatePasses} is the second, conditional gate behind {@code
|
||||||
|
* sendMessage}'s coarse {@link Authz.Action#SEND} check. With the {@code ANSWER} grant denied,
|
||||||
|
* a {@code turnId}-bearing request is refused while a plain one still passes — and the denied
|
||||||
|
* permit is queried only for the {@code turnId} case, never for the plain one, which is what
|
||||||
|
* proves this is a genuinely separate, conditional check rather than the {@code SEND} check
|
||||||
|
* renamed or an unconditional call whose result is ignored. Flipping only the {@code ANSWER}
|
||||||
|
* grant to allowed then flips only the {@code turnId} shape's outcome.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void answerGatePassesOnlyWhenTurnIdAbsentOrAnswerGranted() {
|
||||||
|
List<Authz.Action> queried = new ArrayList<>();
|
||||||
|
Predicate<Authz.Action> denyAnswer = action -> {
|
||||||
|
queried.add(action);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
assertFalse(FleetApp.answerGatePasses("turn-1", denyAnswer),
|
||||||
|
"ANSWER denied ⇒ the turnId shape is refused");
|
||||||
|
assertEquals(List.of(Authz.Action.ANSWER), queried,
|
||||||
|
"the ANSWER grant, specifically, must be the one consulted");
|
||||||
|
|
||||||
|
queried.clear();
|
||||||
|
assertTrue(FleetApp.answerGatePasses(null, denyAnswer),
|
||||||
|
"no turnId ⇒ the plain shape passes even though ANSWER is denied");
|
||||||
|
assertTrue(FleetApp.answerGatePasses(" ", denyAnswer), "a blank turnId is treated as absent");
|
||||||
|
assertEquals(List.of(), queried, "the plain shape must never consult the permit at all");
|
||||||
|
|
||||||
|
assertTrue(FleetApp.answerGatePasses("turn-1", action -> true),
|
||||||
|
"flipping only the ANSWER grant to allowed flips only the turnId shape's outcome");
|
||||||
|
}
|
||||||
|
|
||||||
private static Set<String> routesTheServerRegisters() {
|
private static Set<String> routesTheServerRegisters() {
|
||||||
try {
|
try {
|
||||||
String source = Files.readString(REST_SOURCE).lines()
|
String source = Files.readString(REST_SOURCE).lines()
|
||||||
@@ -147,6 +656,95 @@ class FleetAppAuthTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code permitsFor} is the exact decision {@link FleetApp#allow} makes, passing the real
|
||||||
|
* production classifier rather than a test-supplied one — built from an empty {@link
|
||||||
|
* CallerResolver}, so no terminal is recognised as a configured lead or collaborator and a
|
||||||
|
* collaborator's SEND is refused through the REST gate.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMayNotSendOverRestWithTheRealProductionClassifier() {
|
||||||
|
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(new FakeHerdr()), _ -> 700L);
|
||||||
|
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
|
||||||
|
Map::of, new MemberRegistry(null));
|
||||||
|
Principal collaborator = Principal.collaborator("ops", "term_collab", 700);
|
||||||
|
assertFalse(FleetApp.permitsFor(collaborator, Authz.Action.SEND, "term_lead",
|
||||||
|
callers.knownLeadOrCollaborator()),
|
||||||
|
"no terminal is recognised as a lead or collaborator yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit D: wires a real {@link CallerResolver} with a known lead and a known
|
||||||
|
* collaborator tab, and a spawned member's own terminal recognised by neither map. A
|
||||||
|
* collaborator's SEND reaches the known lead and the known collaborator, and is refused for the
|
||||||
|
* spawned member's terminal — over the REST route, not just the unit-level classifier, so a
|
||||||
|
* test covering only MCP cannot leave this route open.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMaySendToAKnownLeadOrCollaboratorButNotToASpawnedMembersTerminalOverRest() throws Exception {
|
||||||
|
int port = startWithRealClassifier(FakeHerdr.WORKER_PID,
|
||||||
|
Map.of("term_lead_known", "lead-x"), Map.of("term_a", "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> toSpawnedMembersTerminal = send(port, "POST", "/sessions/term_worker/message",
|
||||||
|
"{\"content\":\"hi\",\"wait\":false}", null);
|
||||||
|
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
|
||||||
|
* of the default empty one.
|
||||||
|
*/
|
||||||
|
private int startWithRealClassifier(long pid, Map<String, String> leadTerminals,
|
||||||
|
Map<String, String> collaboratorTerminals) {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
FleetConfig.Profile wcfg = new FleetConfig.Profile(
|
||||||
|
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null,
|
||||||
|
"tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
|
||||||
|
AgentControl agents = new AgentControl(herdr);
|
||||||
|
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(
|
||||||
|
agents, new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")),
|
||||||
|
Map.of(wcfg.profile(), wcfg), wcfg.profile(),
|
||||||
|
k -> "FLEETD_WORKER_TOKEN".equals(k) ? "tok-abc" : null);
|
||||||
|
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
|
||||||
|
Injector injector = new Injector(agents);
|
||||||
|
MessageService messages = new MessageService(agents, injector, new Rendezvous());
|
||||||
|
|
||||||
|
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
|
||||||
|
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
|
||||||
|
() -> leadTerminals, new MemberRegistry(null), t -> null, () -> collaboratorTerminals);
|
||||||
|
metrics = FleetMetrics.create(sessions, new dev.ltms.fleet.msg.InMemoryReplyInbox());
|
||||||
|
|
||||||
|
app = new FleetApp(herdr, workers, sessions, messages, sessions.asPresence(), null,
|
||||||
|
callers, metrics).build().start("127.0.0.1", 0);
|
||||||
|
return app.port();
|
||||||
|
}
|
||||||
|
|
||||||
// --- loopback-trust: the caller is the primary -------------------------------------------
|
// --- loopback-trust: the caller is the primary -------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -192,6 +790,107 @@ class FleetAppAuthTest {
|
|||||||
"draining an inbox is the primary's collection step");
|
"draining an inbox is the primary's collection step");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit A: the {@code turnId} shape of {@code POST /sessions/{id}/message} maps to
|
||||||
|
* {@link Authz.Action#ANSWER}, not the plain {@link Authz.Action#SEND} the test above drives —
|
||||||
|
* a worker must stay refused on this shape too, exactly as it was refused on the one undivided
|
||||||
|
* action before the split.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aWorkerMayNotAnswerAnotherSessionsBlockedQuestionOverRest() throws Exception {
|
||||||
|
int port = start(FakeHerdr.WORKER_PID, false, null);
|
||||||
|
|
||||||
|
assertEquals(403, send(port, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null).statusCode(),
|
||||||
|
"resolving another session's blocked question would be a worker escalating too");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #689: a caller refused the coarse {@link Authz.Action#SEND} grant is refused on
|
||||||
|
* {@code SEND} specifically, even on the {@code turnId}-bearing shape that otherwise raises
|
||||||
|
* the check to {@link Authz.Action#ANSWER} — proving {@code turnId} was never read from the
|
||||||
|
* body before the refusal (reading it would have changed which action is named in the 403).
|
||||||
|
* The same caller refused with no body at all gets the identical detail, which could not hold
|
||||||
|
* if the decision depended on anything read from the body. Control: a caller who IS granted
|
||||||
|
* reaches past the gate and the body is used normally.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aDeniedCallerIsRefusedOnSendEvenWithATurnIdBodyAndNeverReadsTheBody() throws Exception {
|
||||||
|
int workerPort = start(FakeHerdr.WORKER_PID, false, null); // denied: not primary/architect
|
||||||
|
|
||||||
|
HttpResponse<String> withTurnId = send(workerPort, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null);
|
||||||
|
assertEquals(403, withTurnId.statusCode());
|
||||||
|
assertTrue(withTurnId.body().contains("may not SEND"),
|
||||||
|
"the SEND check must be the one that fired, not ANSWER — ANSWER would only be "
|
||||||
|
+ "reachable by having already read turnId out of the body");
|
||||||
|
|
||||||
|
HttpResponse<String> noBody = send(workerPort, "POST", "/sessions/term_b/message", null, null);
|
||||||
|
assertEquals(403, noBody.statusCode());
|
||||||
|
assertTrue(noBody.body().contains("may not SEND"),
|
||||||
|
"refused identically with no body at all — the refusal cannot depend on body content");
|
||||||
|
|
||||||
|
// Control: a primary IS granted SEND, so the same turnId body is read and acted on —
|
||||||
|
// reaching messages.answer, which reports this unknown turnId as a stale one.
|
||||||
|
int primaryPort = start(999_999, false, null);
|
||||||
|
HttpResponse<String> granted = send(primaryPort, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null);
|
||||||
|
assertEquals(409, granted.statusCode());
|
||||||
|
assertTrue(granted.body().contains("stale_turn"), "a granted caller's body IS read and acted on");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #689 (ticket comment 18353): the only place {@code sendMessage}'s call to {@code
|
||||||
|
* answerGatePasses} is observable is the audit trail — {@code allow()} logs an {@code
|
||||||
|
* "allowed"} entry for every granted action except {@code READ}/{@code METRICS}/{@code
|
||||||
|
* TASK_READ}, and {@code ANSWER} is none of those. A granted {@code turnId} request must
|
||||||
|
* therefore log both a {@code SEND} and an {@code ANSWER} entry; a granted plain request must
|
||||||
|
* log {@code SEND} alone. A unit test of the extracted helper pins the helper; this pins the
|
||||||
|
* call site — deleting the {@code answerGatePasses} call from {@code sendMessage} leaves the
|
||||||
|
* helper's own test green but turns this one red.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aGrantedTurnIdRequestAuditsBothSendAndAnswerButAPlainRequestAuditsSendAlone() throws Exception {
|
||||||
|
int port = start(999_999, false, null); // primary: granted both SEND and ANSWER
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
try (CapturedLog audit = CapturedLog.at("audit", Level.INFO)) {
|
||||||
|
send(port, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null);
|
||||||
|
|
||||||
|
List<String> allowed = allowedActions(audit, mapper);
|
||||||
|
assertTrue(allowed.contains("SEND"),
|
||||||
|
"a turnId request must still clear the coarse SEND grant first");
|
||||||
|
assertTrue(allowed.contains("ANSWER"),
|
||||||
|
"a turnId request must ALSO clear the ANSWER grant — this is the call site itself");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (CapturedLog audit = CapturedLog.at("audit", Level.INFO)) {
|
||||||
|
send(port, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"content\":\"hi\",\"timeoutMs\":50}", null);
|
||||||
|
|
||||||
|
List<String> allowed = allowedActions(audit, mapper);
|
||||||
|
assertEquals(List.of("SEND"), allowed,
|
||||||
|
"a plain request must log SEND and nothing else — ANSWER is conditional on "
|
||||||
|
+ "turnId, not something every request happens to log");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> allowedActions(CapturedLog audit, ObjectMapper mapper) {
|
||||||
|
return audit.events().stream()
|
||||||
|
.map(ILoggingEvent::getFormattedMessage)
|
||||||
|
.map(line -> {
|
||||||
|
try {
|
||||||
|
return mapper.readTree(line);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new AssertionError("audit line is not valid JSON: " + line, e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(n -> "allowed".equals(n.path("outcome").asText()))
|
||||||
|
.map(n -> n.path("action").asText())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// --- token mode ---------------------------------------------------------------------------
|
// --- token mode ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -223,6 +223,37 @@ class FleetAppTest {
|
|||||||
assertTrue(body.has("detail"), res.body());
|
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
|
@Test
|
||||||
void spawnWorkerLandsInOwnTabInWorkerSpaceAndInjectsBaseUrl() throws Exception {
|
void spawnWorkerLandsInOwnTabInWorkerSpaceAndInjectsBaseUrl() throws Exception {
|
||||||
FakeHerdr herdr = new FakeHerdr();
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
@@ -675,6 +706,26 @@ class FleetAppTest {
|
|||||||
assertEquals(400, postMessage(port, "{}").statusCode());
|
assertEquals(400, postMessage(port, "{}").statusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #689: a body that fails to parse is rejected with 400 before {@code turnId} is ever
|
||||||
|
* read from it, so it reaches neither {@code messages.answer} (which needs a {@code turnId})
|
||||||
|
* nor {@code messages.send} — confirmed here for {@code send} by the fake agent's idle status,
|
||||||
|
* which would otherwise make an immediate {@code agent.prompt} delivery observable.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void malformedBodyReturns400AndNeverReachesSendOrAnswer() throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().agentStatus("idle"); // idle ⇒ send would deliver right away if reached
|
||||||
|
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||||
|
|
||||||
|
HttpResponse<String> res = postMessage(port, "not json at all");
|
||||||
|
assertEquals(400, res.statusCode());
|
||||||
|
JsonNode err = mapper.readTree(res.body());
|
||||||
|
assertEquals("bad_request", err.get("error").asText());
|
||||||
|
assertEquals("body must be JSON", err.get("detail").asText());
|
||||||
|
assertFalse(herdr.called("agent.prompt"),
|
||||||
|
"a malformed body must never reach messages.send's delivery");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sessionStatusReportsLiveAgentStatus() throws Exception {
|
void sessionStatusReportsLiveAgentStatus() throws Exception {
|
||||||
FakeHerdr herdr = new FakeHerdr().agentStatus("blocked");
|
FakeHerdr herdr = new FakeHerdr().agentStatus("blocked");
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package dev.ltms.fleet.session;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.inject.MemberPresence;
|
||||||
|
import dev.ltms.fleet.peer.Capability;
|
||||||
|
import dev.ltms.fleet.peer.PeerHandle;
|
||||||
|
import dev.ltms.fleet.peer.PeerLauncher;
|
||||||
|
import dev.ltms.fleet.peer.SpawnRequest;
|
||||||
|
import dev.ltms.fleet.placement.PlacementDecision;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link PeerLauncher} decorator that marks presence for a spawned terminal before returning its
|
||||||
|
* handle to the caller — the contact-then-register ordering fleetd #722 covers, where the
|
||||||
|
* terminal's MCP contact lands before {@link SessionManager#acquire} runs its own
|
||||||
|
* {@code registry.put}. The presence view is set after construction, via {@link #presence},
|
||||||
|
* because it is owned by the {@link SessionManager} this launcher is passed into.
|
||||||
|
*/
|
||||||
|
final class PresenceRacingLauncher implements PeerLauncher {
|
||||||
|
|
||||||
|
private final PeerLauncher delegate;
|
||||||
|
volatile MemberPresence presence;
|
||||||
|
|
||||||
|
PresenceRacingLauncher(PeerLauncher delegate) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PeerHandle spawn(SpawnRequest req) {
|
||||||
|
PeerHandle handle = delegate.spawn(req);
|
||||||
|
presence.markPresent(handle.terminalId());
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PeerHandle spawn(SpawnRequest req, PlacementDecision decision) {
|
||||||
|
PeerHandle handle = delegate.spawn(req, decision);
|
||||||
|
presence.markPresent(handle.terminalId());
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<Capability> capabilities() {
|
||||||
|
return delegate.capabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<Capability> capabilitiesFor(String profileName) {
|
||||||
|
return delegate.capabilitiesFor(profileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> profiles() {
|
||||||
|
return delegate.profiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String defaultProfile() {
|
||||||
|
return delegate.defaultProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String effectiveCwd(SpawnRequest req) {
|
||||||
|
return delegate.effectiveCwd(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> parityOverlay(String profileName) {
|
||||||
|
return delegate.parityOverlay(profileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<?> list() {
|
||||||
|
return delegate.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int reapOrphanWorkers() {
|
||||||
|
return delegate.reapOrphanWorkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop(String id) {
|
||||||
|
delegate.stop(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean clearContext(String id) {
|
||||||
|
return delegate.clearContext(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -422,6 +422,53 @@ class SessionManagerTest {
|
|||||||
"turn completion moves BUSY → DONE");
|
"turn completion moves BUSY → DONE");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fleetd #722: registration and presence must reach READY whichever lands first --------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerThenContactReachesReadyForPlainSpawn() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr);
|
||||||
|
MemberSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||||
|
|
||||||
|
sessions.asPresence().markPresent(session.terminalId());
|
||||||
|
|
||||||
|
assertEquals(MemberSession.State.READY, sessions.get(session.paneId()).orElseThrow().state(),
|
||||||
|
"a presence contact that arrives after registration reaches READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contactThenRegisterStillReachesReadyForPlainSpawn() {
|
||||||
|
// The racing launcher marks presence for the spawned terminal from inside spawn() —
|
||||||
|
// before SessionManager.acquire's own registry.put runs — modeling an MCP contact that
|
||||||
|
// lands in that window.
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
FleetConfig.Profile cfg = new FleetConfig.Profile(
|
||||||
|
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
|
||||||
|
List.of("ccs", "ltms-local"), "tab", "fleetd-workers",
|
||||||
|
"worker: {profile} #{n}", null, null, null);
|
||||||
|
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||||
|
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||||
|
PresenceRacingLauncher race = new PresenceRacingLauncher(workers);
|
||||||
|
SessionManager sessions = new SessionManager(race);
|
||||||
|
race.presence = sessions.asPresence();
|
||||||
|
|
||||||
|
MemberSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||||
|
|
||||||
|
assertEquals(MemberSession.State.READY, sessions.get(session.paneId()).orElseThrow().state(),
|
||||||
|
"a presence contact that lands before registry.put must still reach READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aTerminalNeverMarkedPresentStaysSpawningAfterRegistration() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr);
|
||||||
|
|
||||||
|
MemberSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||||
|
|
||||||
|
assertEquals(MemberSession.State.SPAWNING, sessions.get(session.paneId()).orElseThrow().state(),
|
||||||
|
"registration alone must not advance a terminal that was never marked present");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void releaseTearsDownWorkerAndRemovesFromRosterAndIsIdempotent() {
|
void releaseTearsDownWorkerAndRemovesFromRosterAndIsIdempotent() {
|
||||||
FakeHerdr herdr = new FakeHerdr();
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
@@ -1405,6 +1452,105 @@ class SessionManagerTest {
|
|||||||
+ "dirty check threw");
|
+ "dirty check threw");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fleetd #736: a release must forget the member's presence entry, not just its registry
|
||||||
|
// row ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releaseByPaneIdForgetsThePresenceEntry() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr);
|
||||||
|
MemberSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||||
|
String terminal = session.terminalId();
|
||||||
|
sessions.asPresence().markPresent(terminal);
|
||||||
|
assertTrue(sessions.asPresence().isPresent(terminal), "present before the release");
|
||||||
|
|
||||||
|
sessions.release(session.paneId());
|
||||||
|
|
||||||
|
assertFalse(sessions.asPresence().isPresent(terminal),
|
||||||
|
"release must forget the terminal's presence, not just remove its registry row");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reapIdleForgetsThePresenceEntryToo() {
|
||||||
|
long[] clock = {0};
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr, () -> clock[0]);
|
||||||
|
MemberSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||||
|
String terminal = session.terminalId();
|
||||||
|
sessions.asPresence().markPresent(terminal);
|
||||||
|
assertTrue(sessions.asPresence().isPresent(terminal), "present before the reap");
|
||||||
|
|
||||||
|
clock[0] = 11;
|
||||||
|
assertEquals(1, sessions.reapIdle(10), "READY session past TTL is reaped");
|
||||||
|
|
||||||
|
assertFalse(sessions.asPresence().isPresent(terminal),
|
||||||
|
"the idle-reap release path (releaseIfCurrent) goes through the same teardown "
|
||||||
|
+ "funnel as an explicit release, so it must forget presence too");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shutdownDrainAlsoForgetsThePresenceEntry() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr);
|
||||||
|
MemberSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||||
|
String terminal = session.terminalId();
|
||||||
|
sessions.asPresence().markPresent(terminal);
|
||||||
|
assertTrue(sessions.asPresence().isPresent(terminal), "present before the drain");
|
||||||
|
|
||||||
|
sessions.drainAll(TimeUnit.MILLISECONDS.toNanos(100));
|
||||||
|
|
||||||
|
assertFalse(sessions.asPresence().isPresent(terminal),
|
||||||
|
"a shutdown drain still ends the member's process, so presence must be cleared "
|
||||||
|
+ "exactly as it is for any other release cause");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releaseOfAnUnknownPaneIdDoesNotThrow() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr);
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> sessions.release("no-such-pane"),
|
||||||
|
"releasing a pane id that was never registered must be a no-op, not a throw");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releaseStillForgetsPresenceWhenDirtyCheckThrows() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
RecordingWorktrees worktrees = new RecordingWorktrees();
|
||||||
|
SessionManager sessions = sessionManager(herdr, worktrees);
|
||||||
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
|
||||||
|
new WorktreeRequest("fleetd-736", null));
|
||||||
|
String terminal = s.terminalId();
|
||||||
|
sessions.asPresence().markPresent(terminal);
|
||||||
|
worktrees.failHasUncommittedWith(new WorktreeException("git status exited 128"));
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> sessions.release(s.paneId()),
|
||||||
|
"a throwing dirty check must not abort the release");
|
||||||
|
|
||||||
|
assertFalse(sessions.asPresence().isPresent(terminal),
|
||||||
|
"presence must be forgotten even when the dirty check throws, which pins the "
|
||||||
|
+ "forget call to the finally block that runs no matter what happened above");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releaseLeavesADifferentStillLiveMembersPresenceUntouched() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr);
|
||||||
|
MemberSession released = sessions.acquire("ltms-local", null, "/caller/a", "ownerA");
|
||||||
|
MemberSession stillLive = sessions.acquire("ltms-local", null, "/caller/b", "ownerB");
|
||||||
|
sessions.asPresence().markPresent(released.terminalId());
|
||||||
|
sessions.asPresence().markPresent(stillLive.terminalId());
|
||||||
|
assertTrue(sessions.asPresence().isPresent(stillLive.terminalId()),
|
||||||
|
"present before the release of the other member");
|
||||||
|
|
||||||
|
sessions.release(released.paneId());
|
||||||
|
|
||||||
|
assertFalse(sessions.asPresence().isPresent(released.terminalId()),
|
||||||
|
"the released terminal is forgotten");
|
||||||
|
assertTrue(sessions.asPresence().isPresent(stillLive.terminalId()),
|
||||||
|
"a still-live member's presence must survive an unrelated release");
|
||||||
|
}
|
||||||
|
|
||||||
// --- fleetd #316: the dirty check must be re-taken after the worker is stopped, not trusted
|
// --- fleetd #316: the dirty check must be re-taken after the worker is stopped, not trusted
|
||||||
// stale from before it ------------------------------------------------------------------------
|
// stale from before it ------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -2436,4 +2582,160 @@ class SessionManagerTest {
|
|||||||
return "threw:" + e.getClass().getName() + ":" + e.getMessage();
|
return "threw:" + e.getClass().getName() + ":" + e.getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fleetd #702: spawnedMemberRole must still answer for a pane mid-teardown ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link Worktrees} test double whose {@code hasUncommitted} runs an injected hook before
|
||||||
|
* answering. This is what lets a test resolve a releasing pane's terminal from inside the
|
||||||
|
* window {@link SessionManager#release} opens between removing the registry entry and
|
||||||
|
* actually stopping the pane — the hook runs synchronously on the release call's own thread,
|
||||||
|
* at the exact point {@code release} shells out to {@code git status}, so there is no sleep
|
||||||
|
* and no race to land in it.
|
||||||
|
*/
|
||||||
|
private static final class HookedWorktrees implements Worktrees {
|
||||||
|
private Runnable hook;
|
||||||
|
private boolean dirty = false;
|
||||||
|
|
||||||
|
HookedWorktrees onHasUncommitted(Runnable hook) {
|
||||||
|
this.hook = hook;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String add(String repoRoot, String branch, String baseRef) {
|
||||||
|
return "/wt/" + branch.replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void remove(String repoRoot, String worktreePath) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteBranch(String repoRoot, String branch) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasUncommitted(String worktreePath) {
|
||||||
|
if (hook != null) {
|
||||||
|
hook.run();
|
||||||
|
}
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void overlayParity(String repoRoot, String worktreePath, List<String> overlay) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String repoRoot(String cwd) {
|
||||||
|
return "/repo";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.Optional<String> snapshot(String worktreePath, String branch, String message) {
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WipRefStats wipRefs(String repoRoot) {
|
||||||
|
return new WipRefStats(0, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int pruneWipRefs(String repoRoot, long minAgeMillis) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shareWithGroup(String repoRoot, String worktreePath) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void spawnedMemberRoleResolvesTheLiveRegisteredRole() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr);
|
||||||
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller", null);
|
||||||
|
|
||||||
|
assertEquals(s.role(), sessions.spawnedMemberRole(s.terminalId()),
|
||||||
|
"a registered session resolves to its own role");
|
||||||
|
assertNull(sessions.spawnedMemberRole("term_unknown"),
|
||||||
|
"a terminal with no session at all resolves to null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void spawnedMemberRoleIsNullOnceReleaseFullyCompletes() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(herdr, new HookedWorktrees());
|
||||||
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
|
||||||
|
new WorktreeRequest("fleetd-702a", null));
|
||||||
|
|
||||||
|
sessions.release(s.paneId());
|
||||||
|
|
||||||
|
assertNull(sessions.spawnedMemberRole(s.terminalId()),
|
||||||
|
"once release has fully finished, the terminal is neither registered nor releasing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void spawnedMemberRoleStillAnswersBetweenTheRegistryRemovalAndThePaneStop() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
java.util.concurrent.atomic.AtomicReference<MemberRole> duringWindow = new java.util.concurrent.atomic.AtomicReference<>();
|
||||||
|
HookedWorktrees worktrees = new HookedWorktrees();
|
||||||
|
SessionManager sessions = sessionManager(herdr, worktrees);
|
||||||
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
|
||||||
|
new WorktreeRequest("fleetd-702b", null));
|
||||||
|
worktrees.onHasUncommitted(() -> {
|
||||||
|
// This runs from INSIDE release()'s git-status shell-out: the registry entry is
|
||||||
|
// already gone, but the pane has not stopped yet — a real call landing inside the
|
||||||
|
// exact window fleetd #702 reports, so no sleep and no race is needed to reach it.
|
||||||
|
assertTrue(sessions.get(s.paneId()).isEmpty(),
|
||||||
|
"sanity: the registry entry is already gone at this point");
|
||||||
|
duringWindow.set(sessions.spawnedMemberRole(s.terminalId()));
|
||||||
|
});
|
||||||
|
|
||||||
|
sessions.release(s.paneId());
|
||||||
|
|
||||||
|
assertEquals(s.role(), duringWindow.get(),
|
||||||
|
"spawnedMemberRole must still answer the live role while the pane is mid-teardown, "
|
||||||
|
+ "not only while the session is still in the registry");
|
||||||
|
assertNull(sessions.spawnedMemberRole(s.terminalId()),
|
||||||
|
"and once release has fully finished, the window is closed too");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code Releasing.enter} and {@code Releasing.leave} are called directly here, never through
|
||||||
|
* {@link SessionManager#release} or {@link SessionManager#spawnedMemberRole}, so a test naming
|
||||||
|
* one of them exercises only that one — a regression in the other can never hide behind it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void releasingLeaveStepsDownADepthGreaterThanOneInsteadOfRemovingIt() {
|
||||||
|
SessionManager.Releasing depthTwo = new SessionManager.Releasing(2, "term_a", MemberRole.DEV);
|
||||||
|
|
||||||
|
SessionManager.Releasing afterLeave = depthTwo.leave();
|
||||||
|
|
||||||
|
assertNotNull(afterLeave,
|
||||||
|
"depth 2 means another release of the SAME pane is still mid-teardown; leave() must "
|
||||||
|
+ "step the depth down, never remove the marker outright — removing it here "
|
||||||
|
+ "is what a plain Set would do, and would reopen the window the still-in-"
|
||||||
|
+ "flight release is relying on staying closed");
|
||||||
|
assertEquals(1, afterLeave.depth());
|
||||||
|
assertEquals("term_a", afterLeave.terminalId());
|
||||||
|
assertEquals(MemberRole.DEV, afterLeave.role());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releasingEnterPreservesThePriorTerminalWhenTheOverlappingCallHasNoSessionOfItsOwn() {
|
||||||
|
SessionManager.Releasing prior = new SessionManager.Releasing(1, "term_a", MemberRole.DEV);
|
||||||
|
|
||||||
|
SessionManager.Releasing afterEnter = SessionManager.Releasing.enter(prior, null);
|
||||||
|
|
||||||
|
assertEquals(2, afterEnter.depth(), "depth still increments whether or not this enter knows its session");
|
||||||
|
assertEquals("term_a", afterEnter.terminalId(),
|
||||||
|
"an overlapping release that finds the registry entry already gone has no session of "
|
||||||
|
+ "its own to pass as known, and must not blank out the terminal the first "
|
||||||
|
+ "call already recorded — that terminal is what spawnedMemberRole matches "
|
||||||
|
+ "against");
|
||||||
|
assertEquals(MemberRole.DEV, afterEnter.role());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,40 @@ class WorktreeSessionManagerTest {
|
|||||||
assertEquals(expectedPath, s.cwd(), "session cwd is the worktree path");
|
assertEquals(expectedPath, s.cwd(), "session cwd is the worktree path");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fleetd #722: registration and presence must reach READY whichever lands first --------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerThenContactReachesReadyForWorktreeSpawn() {
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
||||||
|
SessionManager sessions = new SessionManager(workerService(herdr), worktrees);
|
||||||
|
|
||||||
|
MemberSession session = sessions.acquire("ltms-local", null, "/caller/proj", "term_primary",
|
||||||
|
new WorktreeRequest("cb-722", null));
|
||||||
|
sessions.asPresence().markPresent(session.terminalId());
|
||||||
|
|
||||||
|
assertEquals(MemberSession.State.READY, sessions.get(session.paneId()).orElseThrow().state(),
|
||||||
|
"a presence contact that arrives after worktree registration reaches READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contactThenRegisterStillReachesReadyForWorktreeSpawn() {
|
||||||
|
// The racing launcher marks presence for the spawned terminal from inside spawn() —
|
||||||
|
// before SessionManager.acquireWithWorktree's own registry.put runs — modeling an MCP
|
||||||
|
// contact that lands in that window.
|
||||||
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
||||||
|
PresenceRacingLauncher race = new PresenceRacingLauncher(workerService(herdr));
|
||||||
|
SessionManager sessions = new SessionManager(race, worktrees);
|
||||||
|
race.presence = sessions.asPresence();
|
||||||
|
|
||||||
|
MemberSession session = sessions.acquire("ltms-local", null, "/caller/proj", "term_primary",
|
||||||
|
new WorktreeRequest("cb-722", null));
|
||||||
|
|
||||||
|
assertEquals(MemberSession.State.READY, sessions.get(session.paneId()).orElseThrow().state(),
|
||||||
|
"a presence contact that lands before worktree registration must still reach READY");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void worktreeArchitectAcquireAlsoBindsItsSlot() {
|
void worktreeArchitectAcquireAlsoBindsItsSlot() {
|
||||||
FakeHerdr herdr = new FakeHerdr();
|
FakeHerdr herdr = new FakeHerdr();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fleet",
|
"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.",
|
"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.2.0",
|
"version": "0.3.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "LTMS"
|
"name": "LTMS"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"fleet": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "${FLEETD_MCP_URL}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+35
-19
@@ -1,7 +1,7 @@
|
|||||||
# fleet (Claude Code plugin)
|
# fleet (Claude Code plugin)
|
||||||
|
|
||||||
Makes a project **fleet-ready**: mounts the `fleetd` MCP gateway and applies standard Claude Code
|
Makes a project **fleet-ready**: applies standard Claude Code settings and runs the fleet mod, so
|
||||||
settings, so the session can orchestrate a fleet of delegated workers.
|
the session can orchestrate a fleet of delegated workers.
|
||||||
|
|
||||||
**This plugin ships no credentials.** Every secret is referenced by environment-variable *name*;
|
**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.
|
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
|
## What it is not
|
||||||
|
|
||||||
The plugin is the **client-side setup**, not the bridge. `fleetd` is a separate daemon and `herdr`
|
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
|
is a separate PTY multiplexer, each with its own lifecycle and install, and the plugin does not try
|
||||||
already-running daemon and tells you what is missing when one isn't there — it deliberately does
|
to install either on your behalf. It also does not mount the daemon for you — mounting is the
|
||||||
not try to install system services on your behalf.
|
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
|
## Install
|
||||||
|
|
||||||
@@ -33,11 +34,20 @@ not try to install system services on your behalf.
|
|||||||
/plugin install fleet@fleetd
|
/plugin install fleet@fleetd
|
||||||
```
|
```
|
||||||
|
|
||||||
Export the gateway URL — the plugin mounts `${FLEETD_MCP_URL}`, not a hardcoded address, so one
|
Mount the daemon yourself — this plugin carries no mount of its own. Either add the entry below to
|
||||||
plugin serves hosts that run the daemon on different ports:
|
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
|
```json
|
||||||
export FLEETD_MCP_URL=http://127.0.0.1:8765/mcp
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"fleet": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:8765/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, in the project you want to onboard:
|
Then, in the project you want to onboard:
|
||||||
@@ -50,18 +60,24 @@ Then, in the project you want to onboard:
|
|||||||
|
|
||||||
| Component | Effect |
|
| 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. Also the only thing in this plugin that helps mount `fleet`: it writes the project `.mcp.json` entry shown above. |
|
||||||
| `skills/setup` | `/fleet:setup` — preflight, project settings, credential guidance, and verification |
|
| `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
|
Whichever file mounts the daemon, name the server **`fleet`**. That is `PeerLauncher.MCP_MOUNT_NAME`
|
||||||
the name a spawned member's own mount carries. Version 0.1.0 named it `fleetd`, which produced two
|
in the daemon, the name a spawned member's own mount carries, and the name the `mcp__fleet__*`
|
||||||
mounts of one daemon for anyone who also had a project-level `.mcp.json`. Upgrading from 0.1.0 is a
|
role heuristic in `CLAUDE.md` keys on.
|
||||||
**breaking change** — a project that pre-allowed `mcp__fleetd__fleet_whoami` in
|
|
||||||
`.claude/settings.json` must be updated to `mcp__fleet__*`.
|
|
||||||
|
|
||||||
Because the plugin carries its own `.mcp.json`, an installed plugin needs no project-level MCP
|
## Upgrading from 0.2.0 — breaking
|
||||||
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.
|
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
|
## Verifying a setup
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user