From 2d3de2d2aacc0a458277bd471d7e7119a52c9cf2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 10:07:43 +0000 Subject: [PATCH 1/3] feat(fleet): OpenFleet in moshcode: every swarm leaves a record, and `moshcode fleet` reads it (PRD 0016) Implements the moshcode side of the OpenFleet 0.1 spec (logicsrc.com/docs/openfleet). src/openfleet.mjs: the vendored IO over $OPENFLEET_HOME, read on every call like herdDir(): records (written once at 0600, never overwritten), the append-only per-fleet ledger with per-host merge by `at`, claim detection, the end line that counts, the ceiling merge and every narrower case, checkCeiling refusals, context() (own record, else OPENFLEET_FLEET, else current, else the implicit fleet), the - swarm id, and fold() plus renderTree() for the tree. src/swarm.mjs: the swarm id is minted before the plan; the planner, the skeptic and the synthesis run with OPENFLEET_SWARM omitted; the planner's `files` becomes piece.owns; the effective ceiling (parent's chain, narrowed by --agents and --timeout) is checked before anything is written, a refusal appends ceiling.refuse and starts nothing; swarm.spawn precedes the first start; one unclaimed record per member, panes named after member ids, the four OPENFLEET_* variables plus the home in the pane env; member.start on submit for a pane that has none; member.end lines, then one swarm.end (synthesis as summary, --verify as verdict), then the kills in a finally; --keep writes no end lines and names the swarm. --json carries swarm and fleet. src/herd.mjs, src/herd-cli.mjs: startSession extraEnv on both substrates, kept through the strip; `herd start --env KEY=VALUE`; carriesBypass() so the manifest, the JSON and the warning tell the truth about a pane started with the engine's bypass flags; roster rows carry fleet, swarm, member and approvals; `herd ps` groups by fleet then swarm and marks bypass; restore hands the env back. src/fleet-cli.mjs: `moshcode fleet open|cap|tree|stop|log`, every verb --json. open and cap refuse with exit 4 when OPENFLEET_MEMBER is set; stop refuses outside what that member spawned, ends nested swarms first, members through their engines (herd kill, `claude stop`, a pid), then one swarm.end; tree joins the herd roster, draws recordless sessions as implicit-fleet roots and writes member.end lost for a claimed pane the roster dropped; cap stops members above the new ceiling. Wiring: bin dispatch, pit /fleet, schema entry + FLEET_VERBS + pit entry, moshscript fleet(), README table regenerated plus a fleet section, PRD 0016, PRD 0015 R3/R6 naming, version 0.99.0. Tests: test/openfleet.test.mjs, test/fleet-cli.test.mjs, and the swarm, herd and herd-cli suites extended; every fleet test runs under a mkdtemp OPENFLEET_HOME. Suite: 3035 tests, 0 fail (was 2980). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --- README.md | 44 +- bin/moshcode.mjs | 7 + package.json | 2 +- prd/0015-swarm-one-task-a-herd-of-agents.md | 4 +- ...nfleet-the-record-a-swarm-leaves-behind.md | 84 ++ prd/README.md | 1 + src/cli-schema.mjs | 93 ++- src/commands.mjs | 1 + src/fleet-cli.mjs | 483 ++++++++++++ src/herd-cli.mjs | 122 ++- src/herd.mjs | 23 +- src/openfleet.mjs | 736 ++++++++++++++++++ src/swarm.mjs | 320 ++++++-- src/tui.mjs | 5 + test/fleet-cli.test.mjs | 369 +++++++++ test/herd-cli.test.mjs | 59 +- test/herd.test.mjs | 50 +- test/openfleet.test.mjs | 446 +++++++++++ test/swarm.test.mjs | 506 ++++++++++-- 19 files changed, 3161 insertions(+), 194 deletions(-) create mode 100644 prd/0016-openfleet-the-record-a-swarm-leaves-behind.md create mode 100644 src/fleet-cli.mjs create mode 100644 src/openfleet.mjs create mode 100644 test/fleet-cli.test.mjs create mode 100644 test/openfleet.test.mjs diff --git a/README.md b/README.md index 441918cf..b854a81e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ or miss one that does. A test fails the build when it drifts. | `moshcode start` | engines | launch an engine with its native defaults | | `moshcode herd` | runtime | run agent sessions that outlive this terminal | | `moshcode swarm` | runtime | one task, a herd of agents, one answer — plan, fan out, verify, synthesise (PRD 0015) | +| `moshcode fleet` | runtime | the OpenFleet sysop tool: open a fleet, cap it, see the tree, stop a swarm, read the ledger (PRD 0016) | | `moshcode ps` | runtime | list herd sessions and what each one is doing | | `moshcode cost`
`usage` | runtime | what each session is spending, read from the engines' own logs | | `moshcode attach` | runtime | attach this terminal to a herd session | @@ -482,13 +483,13 @@ on any engine moshcode can start: ```sh moshcode swarm "port the auth routes and the dashboard to the new API" · plan — claude is splitting the task into up to 4 pieces - 1 auth routes - 2 dashboard - 3 shared API client -· swarm — 3 sessions, 3 at a time (claude, herd swarm) - ✓ swarm-port-the-auth-1 idle · t-01 - ✓ swarm-port-the-auth-2 idle · t-02 - ✓ swarm-port-the-auth-3 idle · t-03 + 1 auth routes owns src/auth/ + 2 dashboard owns src/dashboard/ + 3 shared API client owns src/api/client.js +· swarm port-the-auth-routes-an-1412: 3 sessions, 3 at a time (claude, herd swarm, fleet anthony@dev) + ✓ port-the-auth-routes-an-1412-1 idle · t-01 + ✓ port-the-auth-routes-an-1412-2 idle · t-02 + ✓ port-the-auth-routes-an-1412-3 idle · t-03 · synthesis — claude is folding 3 pieces into one answer ``` @@ -502,6 +503,35 @@ you read. `--verify` adds a skeptic per piece whose verdict the synthesis sees; in `moshcode ps`. A plan that does not parse runs the task as one piece rather than not at all. +### The fleet: who started what, under whose approval + +Every swarm is recorded as [OpenFleet](https://logicsrc.com/docs/openfleet): +the swarm id is minted before the plan, each member gets a record file and +the `OPENFLEET_*` variables beside its herd ones, and the fleet's ledger says +when the swarm was spawned, when each member started and ended, what the +synthesis said, and what was refused. The pane names are the member ids. A +bypass flag the ceiling forbids is refused before anything starts, and the +refusal is a ledger line the human reads. `moshcode fleet` is the sysop tool +over those files, the same files `logicsrc fleet` reads: + +```sh +moshcode fleet tree +anthony@dev (implicit fleet, sysop anthony@dev, depth 1, hosts dev) +└─ 460a4502 claude-code running [bypass] + └─ swarm create-two-0541 "create two ..." 2/4 members until 06:11 + ├─ create-two-0541-1 (172ffd83) create hello.sh bash claude-code done [bypass] owns hello.sh + └─ create-two-0541-2 create bye.sh bash moshcode/claude working [bypass] owns bye.sh + +moshcode fleet log --swarm create-two-0541 # spawn, starts, ends, in order, who did each +moshcode fleet stop create-two-0541 # end it as one unit, nested swarms first +moshcode fleet open --approvals native --depth 2 # a fleet with a ceiling; new roots join it +``` + +`open` and `cap` are the sysop's alone: a process carrying `OPENFLEET_MEMBER` +is an agent and is refused. `moshcode ps` groups its rows by fleet and swarm +and marks every session whose approvals are bypassed. The records live under +`$OPENFLEET_HOME`, default `~/.openfleet`. + ### Let the engine say what it is doing Reading a screen works and it rots — engines change their wording between diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 2c3d1df7..d9f5e7e4 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -363,6 +363,13 @@ async function main() { process.exitCode = (await swarmCommand(rest)) || 0; return; } + // The fleet (PRD 0016): the OpenFleet sysop tool over the records and the + // ledger a swarm writes. Lazy for the same reason as swarm. + if (cmd === "fleet") { + const { fleetCommand } = await import("../src/fleet-cli.mjs"); + process.exitCode = (await fleetCommand(rest)) || 0; + return; + } if (["ps", "attach", "kill", "wait", "restore", "cost", "usage"].includes(cmd)) { process.exitCode = (await herdCommand([cmd === "usage" ? "cost" : cmd, ...rest])) || 0; return; diff --git a/package.json b/package.json index 1646fec6..115682f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moshcode", - "version": "0.98.2", + "version": "0.99.0", "type": "module", "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript", "repository": { diff --git a/prd/0015-swarm-one-task-a-herd-of-agents.md b/prd/0015-swarm-one-task-a-herd-of-agents.md index 189d8c2d..0a065c7e 100644 --- a/prd/0015-swarm-one-task-a-herd-of-agents.md +++ b/prd/0015-swarm-one-task-a-herd-of-agents.md @@ -55,13 +55,13 @@ R1. `moshcode swarm ""` plans the task, fans it out, and prints one synthe R2. Planning is one headless engine call (`ai()`'s path, `aiExecArgs`). The engine is asked for a JSON array of at most `--agents` pieces, each a self-contained prompt that names the files it may touch and ends with a SUMMARY section. The first JSON array in the reply is the plan. A reply that does not parse, or a call that fails, degrades to one piece holding the whole task, and says so. -R3. Fan-out starts one herd session per piece with `herd start --agent`, named `swarm--`, in the herd `swarm` (`--herd` overrides), at most `--agents` at a time (default 4, maximum 16). Each session is waited on until it draws its prompt, then prompted exactly as `herd prompt --wait` does, so the ledger holds the piece's output. +R3. Fan-out starts one herd session per piece with the engine's own bypass flags as plain args, named after the piece's OpenFleet member id, `-` where the swarm id is `-` (PRD 0016; before 0.99.0 the names were `swarm--`), in the herd `swarm` (`--herd` overrides), at most `--agents` at a time (default 4, maximum 16). Each session is waited on until it draws its prompt, then prompted exactly as `herd prompt --wait` does, so the ledger holds the piece's output. R4. `--verify` runs one headless skeptic per piece, prompted to refute it and to default to refuted when unsure. Its verdict is attached to the piece and shown to the synthesis; it never drops a piece on its own. R5. Synthesis is one headless call over the pieces' outputs, truncated per piece, that writes the answer the operator should read: what was done, found, unfinished or contradicted, and what to do next. -R6. Sessions are ended when the swarm is done. `--keep` leaves them for inspection and names them. The ledger is never pruned by a swarm. +R6. Sessions are ended when the swarm is done, after the fleet ledger's end lines are written (PRD 0016). `--keep` leaves them for inspection and names them and the swarm. The ledger is never pruned by a swarm. R7. `--plan-only` prints the plan and starts nothing. `--json` prints the whole run as data: engine, plan, one row per piece with session, task id, state, outcome, output and verdict, and the synthesis. diff --git a/prd/0016-openfleet-the-record-a-swarm-leaves-behind.md b/prd/0016-openfleet-the-record-a-swarm-leaves-behind.md new file mode 100644 index 00000000..2a38b86c --- /dev/null +++ b/prd/0016-openfleet-the-record-a-swarm-leaves-behind.md @@ -0,0 +1,84 @@ +--- +openprd: "0.3" +id: "0016" +title: "OpenFleet: the record a swarm leaves behind, and the fleet verb that reads it" +status: Draft +authors: + - anthony@profullstack.com +created: 2026-09-13 +updated: 2026-09-13 +repo: https://github.com/moshcoder/moshcode +discussion: +implementation: src/openfleet.mjs, src/swarm.mjs, src/fleet-cli.mjs +tags: + - herd + - swarm + - openfleet + - sysop +supersedes: +superseded-by: +--- + +## Problem + +On 2026-09-13 a Claude Code job asked `moshcode swarm` to split a task across two agents. One piece became a Claude Code job, the other a tmux pane. Neither could say who had started it, why, who its sibling was, what it was allowed to touch, or which human had approved any of it. The herd's manifest said `agent: false` about a pane running `claude --dangerously-skip-permissions`. The human could not see the tree, stop the pair as one unit, or cap what they could do. The relationship existed in a process tree and in a prompt the planner wrote, and nowhere else. + +OpenFleet (logicsrc.com/docs/openfleet, 0.1) is the record and the ledger that fix this: one JSON file per member saying where it sits, and one append-only ledger per fleet per host saying what happened, who did it, and what was refused. This PRD is moshcode's side of it: what `moshcode swarm` writes, what a pane is told, and the sysop tool that reads it back. + +## Goals + +- A swarm's members are recorded before they start, in the files every OpenFleet tool reads, so `moshcode fleet tree`, `logicsrc fleet tree` and Claude Code's own view show one tree. +- What a member may write is data, not prose: the planner's `files` per piece becomes `piece.owns`. +- The manifest tells the truth about approvals. A pane started with the engine's bypass flag says so, and a bypass the ceiling forbids is refused and written down. +- The sysop can see the tree, stop a swarm as one unit, cap what runs, and read what happened, with five verbs over `$OPENFLEET_HOME`. +- Nothing about this needs a network, a daemon, or the other tools to be installed. Files on one host, under one account. + +## Non-Goals + +- Orchestration changes. The four phases of PRD 0015 stay as they are; this records them. +- A permission system. `piece.owns` and `approvals` are carried and honoured by engines; the ledger makes what they did checkable. +- Cross-host ledgers. A fleet spanning hosts merges per-host files by hand in 0.1. +- Stopping engines moshcode did not start. `moshcode fleet stop` ends its own panes, a Claude Code job through `claude stop`, a `claude -p` through its pid, and reports anything else. + +## Users + +- The human who runs `moshcode swarm` and wants to know, afterwards or during, what it started and under what terms. +- An agent that runs `moshcode swarm` from inside a member, whose swarm must be visible as its own and stoppable by its sysop. +- Another sysop tool reading the same files. + +## Requirements + +- R1 [P0] `moshcode swarm` mints the swarm id, `-`, before the plan call. Member ids are `-` and are also the pane names. `--json` carries `swarm` and `fleet`. +- R2 [P0] The planner, the skeptic and the synthesis run without `OPENFLEET_SWARM`: the swarm's `swarm.spawn` does not exist when the planner runs, and a `claude -p` that found the id would join a swarm that has no line. +- R3 [P0] The planner's reply is `[{ "title", "prompt", "files" }]`. `files`, when given, becomes the member's `piece.owns` in its record, in `swarm.spawn` and in `member.start`. +- R4 [P0] The effective ceiling is checked before anything is written: the fleet's, merged with the parent's chain, narrowed by `fan_out` from `--agents` and `until` from `--timeout`. `depth`, `fan_out`, `hosts` and `until` refuse the spawn once; `approvals` refuses each member. A refusal appends `ceiling.refuse`, starts nothing, writes no record, and reports the pieces as `outcome: "refused"`. +- R5 [P0] `swarm.spawn` is written before the first member starts, with the task, the narrowed keys, and one `{ member, title, owns }` per piece. `by` is the member moshcode runs inside, or `sysop` by hand. `parent_swarm` is that member's own swarm when it has one. +- R6 [P0] One unclaimed record per member, written before its session starts and never rewritten: `openfleet`, `fleet`, `sysop`, `member`, `parent`, `swarm`, `task`, `piece`, `depth`, `engine` (`moshcode/`), `session` (the pane name, which is the tmux target), `host`, `cwd`, `started`, `approvals`, `ceiling`. Files are 0600, directories 0700. +- R7 [P0] The pane's environment carries `OPENFLEET_HOME`, `OPENFLEET_RECORD`, `OPENFLEET_FLEET`, `OPENFLEET_MEMBER` and `OPENFLEET_SWARM` beside `MOSHCODE_HERD_NAME` and `MOSHCODE_HERD_DIR`, on the same `env` prefix, so they survive the engine's strip of `ANTHROPIC_API_KEY` and `CLAUDE_CODE_SESSION_ID`. `herd start --env KEY=VALUE` is the flag; `startSession` takes `extraEnv`. +- R8 [P0] When the prompt is submitted and the ledger holds no `member.start` for the member, moshcode writes one on the pane's behalf, `by` the starter, at the submit moment. A claude pane with hooks claims its own record first and moshcode writes nothing. +- R9 [P0] The manifest says `approvals: bypass` when the engine's bypass flags are present, whether from `--agent` or as plain args; `herd start --json` and its warning agree. `moshcode ps` groups rows by fleet then swarm and marks bypass members. +- R10 [P0] At the end, for every member with a record and no real end line: `member.end` with state from the herd outcome (matched is done, timeout is timeout, gone is lost, anything else failed) and `summary` from the SUMMARY: section or the tail of what it printed. Then one `swarm.end` if none exists: `done` when every member ended done, else the first of failed, stopped, budget, timeout among them; `summary` the synthesis; `verdict` the `--verify` verdicts as `[{ member, refuted, reason }]`. Then the kills, in a `finally`, so a crash still ends the panes. +- R11 [P0] `--keep` writes the records, `swarm.spawn` and `member.start`, no end lines, no kills, and names the swarm so `moshcode fleet stop ` can end it later. +- R12 [P0] `moshcode fleet open|cap|tree|stop|log` over `$OPENFLEET_HOME`, every verb with `--json`. `open` and `cap` refuse with exit 4 when `OPENFLEET_MEMBER` is set; `stop` refuses anything outside what that member spawned. `tree` joins the herd roster for liveness, draws a herd session with no record as a root of the implicit fleet, and writes `member.end` lost for a claimed moshcode member the roster no longer lists. `stop` ends nested swarms first, each with its own `swarm.end`, then the members through their engines, then the target's `swarm.end`, never two for one swarm. `cap` stops members already above the new ceiling. +- R13 [P1] Everything above is testable without tmux, a model, or a real home: the fleet's files live under a mkdtemp `OPENFLEET_HOME`, the roster and the kills are injectable. +- R14 [P1] `moshcode help fleet`, the README, the pit's `/fleet` and moshscript's `fleet(...)` document the verb; the README table is regenerated from the schema. + +## UX Notes + +- A refused swarm prints why and exits 1. The refusal is in the ledger, which is the point: the sysop finds out what an agent tried. +- `moshcode fleet tree` draws the shape the spec's landing page shows: the fleet line with its ceiling, root members, each member's swarms, each swarm's members, with `[bypass]`, `owns`, `[orphan]`, `[roster]` marks and spend where an engine counted. +- `moshcode fleet log` is one line per event in time order, or `--json` for one object per line as the ledger holds them. +- Making the manifest truthful changes one thing a person might notice: `herd serve` withholds bypass sessions unless `--expose-autonomous`, and swarm panes now count as such. + +## Success Metrics + +- After `moshcode swarm`, `moshcode fleet tree` shows the swarm under the member that started it, with every piece, and `moshcode fleet log --swarm ` reads spawn, starts, ends, end. +- A `moshcode swarm` run inside a member whose ceiling says native ends with `ceiling.refuse` in the ledger and no pane started. +- `moshcode fleet stop ` on a kept swarm ends every pane and leaves one `swarm.end`. + +## Risks & Open Questions + +- The `member.start` a starter writes at submit time can race a claude pane's own hook claiming the record; both check the ledger first, and a reader takes the first line, so a duplicate is noise rather than a wrong tree. +- The pane's `session` equals its member id, so a claude session that claims the record keeps the tmux target as its handle and its own session id appears nowhere; the spec allows this and the tmux target is what a sysop tool stops. +- `until` is one `--timeout` from the spawn; every piece is bounded by that wait and the plan is capped at `--agents`, so one batch is the whole swarm. A later `--agents` smaller than the plan would need the batch arithmetic the code already carries. +- A `moshcode swarm` run inside a member at depth 1 of the implicit fleet is refused on depth. Correct under the spec, and noisy for a tool that spawns many; `moshcode fleet open --depth 2` is the answer. diff --git a/prd/README.md b/prd/README.md index fb13831e..c724bf62 100644 --- a/prd/README.md +++ b/prd/README.md @@ -31,4 +31,5 @@ Start one with `moshcode prd ""` (TUI: `/prd`). | [0013](0013-persistent-ssh-workspaces.md) | Add persistent SSH workspaces for humans and agents | Draft | | [0014](0014-remote-mcp-session-gateway.md) | Expose live Moshcode sessions over remote MCP | Draft | | [0015](0015-swarm-one-task-a-herd-of-agents.md) | Swarm — one task, a herd of agents, one answer | Draft | +| [0016](0016-openfleet-the-record-a-swarm-leaves-behind.md) | OpenFleet: the record a swarm leaves behind, and the fleet verb that reads it | Draft | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index a61d04c9..a1845377 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -130,10 +130,38 @@ export const CORE_CLI_COMMANDS = [ ["moshcode swarm \"audit src/ for unhandled promise rejections\" --agents 8 --verify", "wider, and reviewed"], ["moshcode swarm \"…\" --plan-only", "see how it would split first"], ], - seeAlso: ["herd", "ps", "wait", "run"], + seeAlso: ["herd", "ps", "wait", "run", "fleet"], note: "the same thing claude code calls ultracode, on any engine moshcode can start: one headless call splits the task into pieces that " + "do not touch the same files, each piece runs in its own herd session (`moshcode herd task ` afterwards), and one more call " - + "folds the outputs into the answer. sessions are ended when it is done unless --keep. a plan that does not parse runs the task as one piece.", + + "folds the outputs into the answer. sessions are ended when it is done unless --keep. a plan that does not parse runs the task as one piece. " + + "every swarm is recorded as OpenFleet (PRD 0016): the swarm id is -, its members and panes are -, each member " + + "carries a record under $OPENFLEET_HOME and the four OPENFLEET_* variables, the ledger gets swarm.spawn before the first start, " + + "member.start on submit for a pane that cannot write its own, member.end and one swarm.end (synthesis as summary, --verify as verdict) " + + "before the kills, and a bypass flag the ceiling forbids is refused with ceiling.refuse. `moshcode fleet tree` shows it.", + }, + { + name: "fleet", + group: "runtime", + description: "the OpenFleet sysop tool: open a fleet, cap it, see the tree, stop a swarm, read the ledger (PRD 0016)", + synopsis: [ + ["moshcode fleet", "the tree, same as moshcode fleet tree"], + ["moshcode fleet [args…]", ""], + ], + verbs: "FLEET_VERBS", + flags: [["--json", "machine-readable, on every verb", ""]], + examples: [ + ["moshcode fleet tree", "every fleet on this box: roots, swarms, members, who runs with approvals bypassed"], + ["moshcode fleet open --approvals bypass --depth 2 --until 2h", "a fleet with a ceiling; new root members join it"], + ["moshcode fleet log --swarm create-two-0541", "what one swarm did, in order, and who did it"], + ["moshcode fleet stop create-two-0541", "end a swarm as one unit, through each member's own engine"], + ["moshcode fleet cap create-two-0541 --approvals native", "narrow a running swarm; members above the new ceiling are stopped"], + ], + seeAlso: ["swarm", "herd", "ps"], + note: "the records and the ledger live under $OPENFLEET_HOME (default ~/.openfleet), the same files `logicsrc fleet` and claude code read. " + + "open and cap are the sysop's alone and refuse when OPENFLEET_MEMBER is set, because a process carrying it is an agent; " + + "stop refuses anything outside what that agent spawned. tree joins the herd roster for liveness, draws a herd session with no " + + "record as a root of the implicit fleet @, and marks a recorded member the roster no longer lists as lost. " + + "moshcode stops its own panes; a claude code member goes through `claude stop`, a `claude -p` through its pid.", }, { name: "ps", @@ -1259,6 +1287,7 @@ export const HERD_VERBS = [ ["--name ", "session name", "-"], ["--cwd ", "where to run it", "this directory"], ["--agent", "autonomous mode — bypasses the engine's approvals", ""], + ["--env KEY=VALUE", "a variable for this session alone, repeatable; a swarm hands its members OPENFLEET_* this way", ""], ["--json", "machine-readable", ""], ] }, { name: "tile", description: "every member on screen at once, in a tiled window", @@ -1412,6 +1441,63 @@ export const HERD_VERBS = [ + "CI has to tell a worse agent from a broken box." }, ]; +/** `moshcode fleet`'s verbs (PRD 0016): the spec's five, over $OPENFLEET_HOME. */ +export const FLEET_VERBS = [ + { name: "open", description: "sysop only: create a fleet, set its whole ceiling, make it current", + synopsis: [["moshcode fleet open [name] [--approvals native|bypass] [--budget \"20 USD\"] [--depth 2] [--fan-out 4] [--hosts a,b] [--until 2h]", "prints the fleet id, -"]], + flags: [ + ["--approvals native|bypass", "the most a member may run with; absent means native", "native"], + ["--budget ", "the most the fleet may spend in total, \"20 USD\" or \"5000 tokens\"", "uncapped"], + ["--depth ", "the deepest member allowed; 1 means roots may spawn and their members may not", "1"], + ["--fan-out ", "the most members one swarm may hold", "the engine's default"], + ["--hosts ", "hostnames members may run on", "this host"], + ["--until ", "when every member under it is stopped", "no deadline"], + ["--sysop ", "the human's OpenProfile.md URL", "@"], + ["--json", "machine-readable", ""], + ], + note: "writes fleet.open with by: sysop and the id to $OPENFLEET_HOME/current. refuses with exit 4 when OPENFLEET_MEMBER is set." }, + { name: "cap", description: "sysop only: set a fleet's whole ceiling, or narrow a running swarm's", + synopsis: [["moshcode fleet cap [--approvals …] [--budget …] [--depth …] [--fan-out …] [--hosts …] [--until …]", ""]], + flags: [ + ["--approvals native|bypass", "", ""], + ["--budget ", "", ""], + ["--depth ", "", ""], + ["--fan-out ", "", ""], + ["--hosts ", "", ""], + ["--until ", "", ""], + ["--fleet ", "look for the swarm in this fleet only", "every fleet"], + ["--json", "machine-readable", ""], + ], + note: "on a fleet the keys given are the whole new ceiling; on a swarm they must narrow what it has. members already above the new " + + "ceiling, a bypass member under a now-native ceiling or one on a now-forbidden host, are stopped with member.end stopped. " + + "refuses with exit 4 when OPENFLEET_MEMBER is set." }, + { name: "tree", description: "one fleet, or every fleet on this host, as a tree", + synopsis: [["moshcode fleet tree [fleet] [--json]", ""]], + flags: [["--json", "the folded model: fleets, nodes, refusals, spend", ""]], + note: "fleet, its root members, each member's swarms, each swarm's members: state, engine, depth, spend against budget, owns, and a " + + "[bypass] mark on every member whose approvals are bypassed. a herd session with no record is a root of the implicit fleet " + + "marked [roster]; a recorded moshcode member the roster no longer lists gets member.end lost." }, + { name: "stop", description: "end a member, a swarm, or a whole fleet as one unit", + synopsis: [ + ["moshcode fleet stop ", "nested swarms first, each with its own swarm.end, then the members, then the target's swarm.end"], + ["moshcode fleet stop --fleet ", "everything in the fleet"], + ], + flags: [["--fleet ", "stop every swarm and root member of this fleet", ""], ["--json", "machine-readable", ""]], + note: "each member ends through its own engine: a moshcode pane through the herd, a claude code job through `claude stop`, a `claude -p` " + + "through its pid; one it cannot reach is reported, never faked. an agent (OPENFLEET_MEMBER set) may stop only a swarm it spawned " + + "or a member under one; --fleet, an ancestor or a sibling's swarm is refused with exit 4. a member already ended writes nothing." }, + { name: "log", description: "the ledger for a fleet, a swarm or a member, in order", + synopsis: [["moshcode fleet log [fleet] [--since 1h] [--member ] [--swarm ] [--json]", ""]], + flags: [ + ["--since ", "only lines from then on", "everything"], + ["--member ", "lines about, or by, one member", ""], + ["--swarm ", "one swarm: its spawn, its members' starts and ends, its end", ""], + ["--fleet ", "one fleet", "every fleet, merged by time"], + ["--json", "one JSON object per line, as the ledger holds them", ""], + ], + note: "who did what, in order: by is sysop or a member id; what each member spent, how each ended, what was refused and why." }, +]; + // The business layer's verbs. Flatter than the herd's on purpose: these are // commands somebody types between other work, and a verb that needs a paragraph // to explain itself is a verb in the wrong place. @@ -1543,6 +1629,7 @@ export const PAYMENT_VERBS = [ export const VERB_TABLES = { HERD_VERBS, + FLEET_VERBS, SSH_VERBS, TIMER_VERBS, CLIENT_VERBS, @@ -1587,6 +1674,8 @@ export const PIT_COMMANDS = [ description: "what the herd is running, and which one wants you" }, { name: "swarm", args: " [--agents 4] [--verify]", cli: "swarm", description: "one task, a herd of agents, one answer" }, + { name: "fleet", args: "[verb] [args…]", cli: "fleet", + description: "the fleet tree, and the sysop's verbs over it" }, { name: "cost", aliases: ["usage"], args: "[name] [--all]", cli: "cost", description: "what the herd is spending, from the engines' own logs" }, { name: "attach", args: "", cli: "attach", diff --git a/src/commands.mjs b/src/commands.mjs index 456bd641..072f5836 100644 --- a/src/commands.mjs +++ b/src/commands.mjs @@ -830,6 +830,7 @@ const COMMANDS = [ cliVerb("herd", "drive the herd (moshcode herd ) — see herdStart/herdWait for values"), cliVerb("ps", "print the herd roster"), cliVerb("swarm", "one task, a herd of agents, one answer (moshcode swarm \"\" [--agents 4])"), + cliVerb("fleet", "the OpenFleet sysop tool: open, cap, tree, stop, log (moshcode fleet )"), cliVerb("cost", "print what the herd is spending (moshcode cost [name] [--all])"), cliVerb("start", "raw-launch an engine (moshcode start )"), cliVerb("install", "install an engine or workflow tool"), diff --git a/src/fleet-cli.mjs b/src/fleet-cli.mjs new file mode 100644 index 00000000..c2a8b226 --- /dev/null +++ b/src/fleet-cli.mjs @@ -0,0 +1,483 @@ +// `moshcode fleet`: the OpenFleet sysop tool for this engine (PRD 0016). +// +// Five verbs over `$OPENFLEET_HOME`, the same five `logicsrc fleet` offers over +// the same files: `open` and `cap` are the sysop's alone, `tree`, `stop` and +// `log` are anyone's within reach. The test is the environment: a process that +// carries OPENFLEET_MEMBER is an agent, and an agent never opens a fleet, never +// sets a ceiling, and stops only what it spawned. +// +// Same shape as herd-cli: one verb table, `--json` on every verb, and no +// second API. What this tool can end is what this engine runs: a moshcode pane +// goes through herdKill; a Claude Code job through `claude stop`; a `claude -p` +// through its pid. Anything else is reported, never faked. +import { spawnSync } from "node:child_process"; + +import { EXIT, herdKill, roster as herdRoster } from "./herd-cli.mjs"; +import { slugifyName } from "./herd.mjs"; +import * as fleet from "./openfleet.mjs"; +import { acid, amber, ash, bone, dim, err, info, ok, warn } from "./ui.mjs"; + +/** The exit a refused open, cap or stop returns. logicsrc fleet uses the same number. */ +export const REFUSED = 4; + +const USAGE = { + open: 'usage: moshcode fleet open [name] [--approvals native|bypass] [--budget "20 USD"] [--depth 2] [--fan-out 4] [--hosts a,b] [--until 2h] [--sysop ] [--json]', + cap: "usage: moshcode fleet cap [--approvals native|bypass] [--budget ] [--depth ] [--fan-out ] [--hosts a,b] [--until ] [--json]", + tree: "usage: moshcode fleet tree [fleet] [--json]", + stop: "usage: moshcode fleet stop | --fleet [--json]", + log: "usage: moshcode fleet log [fleet] [--since 1h] [--member ] [--swarm ] [--json]", +}; + +/* ----------------------------------------------------------------- parsing */ + +/** `--key value`, `--key=value`, bare `--flag`, and the positionals. */ +function parseArgs(argv, { valued = [], flags: known = [] } = {}) { + const flags = {}; + const positional = []; + const errors = []; + for (let i = 0; i < argv.length; i++) { + const a = String(argv[i]); + if (!a.startsWith("--") || a === "--") { positional.push(a); continue; } + const eq = a.indexOf("="); + const key = eq > 0 ? a.slice(2, eq) : a.slice(2); + if (valued.includes(key)) { + const value = eq > 0 ? a.slice(eq + 1) : argv[++i]; + if (value === undefined) errors.push(`--${key} needs a value`); + else flags[key] = String(value); + } else if (known.includes(key) && eq < 0) { + flags[key] = true; + } else { + errors.push(`unknown flag ${a}`); + } + } + return { flags, positional, errors }; +} + +function parseDurationMs(raw) { + const m = /^(\d+)(ms|s|m|h|d)?$/.exec(String(raw || "").trim()); + if (!m) return null; + const n = Number(m[1]); + return { ms: n, s: n * 1000, m: n * 60000, h: n * 3600000, d: n * 86400000 }[m[2] || "s"]; +} + +/** `2h` from now, or an ISO 8601 time as given. Null when it is neither. */ +function untilFrom(raw, now) { + const ms = parseDurationMs(raw); + if (ms !== null) return fleet.iso(now + ms); + const t = Date.parse(String(raw || "")); + return Number.isFinite(t) ? fleet.iso(t) : null; +} + +/** The ceiling keys the flags name, validated. Only the keys given. */ +function ceilingFromFlags(flags, { now }) { + const ceiling = {}; + const errors = []; + if (flags.approvals !== undefined) { + if (!["native", "bypass"].includes(flags.approvals)) errors.push("--approvals is native or bypass"); + else ceiling.approvals = flags.approvals; + } + if (flags.budget !== undefined) { + if (!fleet.parseBudget(flags.budget)) errors.push('--budget is " ", like "20 USD" or "5000 tokens"'); + else ceiling.budget = flags.budget.trim(); + } + for (const [flag, key] of [["depth", "depth"], ["fan-out", "fan_out"]]) { + if (flags[flag] === undefined) continue; + const n = Number(flags[flag]); + if (!Number.isInteger(n) || n < 0) errors.push(`--${flag} is a whole number`); + else ceiling[key] = n; + } + if (flags.hosts !== undefined) { + const hosts = flags.hosts.split(",").map((h) => h.trim()).filter(Boolean); + if (!hosts.length) errors.push("--hosts names at least one host"); + else ceiling.hosts = hosts; + } + if (flags.until !== undefined) { + const until = untilFrom(flags.until, now); + if (!until) errors.push("--until is a duration like 2h, or an ISO 8601 time"); + else ceiling.until = until; + } + return { ceiling, errors }; +} + +function describeCeiling(c = {}) { + const parts = []; + for (const key of fleet.CEILING_KEYS) { + if (c[key] === undefined) continue; + parts.push(`${key} ${Array.isArray(c[key]) ? c[key].join(",") : c[key]}`); + } + return parts.join(", ") || "inherited"; +} + +const clip = (s, n) => (String(s ?? "").length > n ? `${String(s).slice(0, n - 3)}...` : String(s ?? "")); + +/** The sentence a human-only verb answers an agent with, or null for a human. */ +function agentRefusal(env, verb) { + if (!env.OPENFLEET_MEMBER) return null; + return `${verb} is the sysop's verb: this process is member ${env.OPENFLEET_MEMBER}${env.OPENFLEET_FLEET ? ` of fleet ${env.OPENFLEET_FLEET}` : ""}, and an agent never ${verb === "open" ? "opens a fleet" : "sets a ceiling"}.`; +} + +/* --------------------------------------------------------------- the world */ + +function liveRoster() { + return herdRoster().map((s) => ({ + name: s.name, engine: `moshcode/${s.engine}`, state: s.state, alive: s.alive, approvals: s.approvals || "native", + cwd: s.cwd, fleet: s.fleet || null, swarm: s.swarm || null, member: s.member || null, + })); +} + +async function liveKill(name) { + const code = await herdKill([name], { write: () => {} }); + return code === EXIT.matched ? { ok: true } : { ok: false, error: "no such session" }; +} + +function liveExec(bin, args) { + const r = spawnSync(bin, args, { encoding: "utf8" }); + if (r.error) return { ok: false, error: r.error.message }; + return r.status === 0 ? { ok: true } : { ok: false, error: String(r.stderr || r.stdout || `exit ${r.status}`).trim() }; +} + +function liveSignal(pid) { + try { process.kill(pid, "SIGTERM"); return { ok: true }; } + catch (error) { return { ok: false, error: error.message }; } +} + +/** Every fleet on disk, read once, as fold() wants it. */ +function loadFleets(env, only = null) { + const names = only ? [only] : fleet.listFleets(env); + return names.map((f) => ({ fleet: f, lines: fleet.readLedger(f, env), records: fleet.listRecords(f, env) })); +} + +function* walkMembers(nodes) { + for (const n of nodes || []) { + if (n.kind === "member") { yield n; yield* walkMembers(n.swarms); } + else yield* walkMembers(n.members); + } +} + +function* walkSwarms(nodes) { + for (const n of nodes || []) { + if (n.kind === "swarm") { yield n; yield* walkSwarms(n.members); } + else yield* walkSwarms(n.swarms); + } +} + +/** Where an id lives: `{ kind: "fleet"|"swarm"|"member", fleet, node }`, or null. */ +function locate(id, model, env) { + if (fleet.listFleets(env).includes(id)) return { kind: "fleet", fleet: id, node: model.fleets.find((f) => f.fleet === id) || null }; + for (const f of model.fleets) { + for (const s of walkSwarms(f.nodes)) if (s.swarm === id) return { kind: "swarm", fleet: f.fleet, node: s }; + } + for (const f of model.fleets) { + for (const m of walkMembers(f.nodes)) if (m.member === id || m.session === id) return { kind: "member", fleet: f.fleet, node: m }; + } + return null; +} + +/* ---------------------------------------------------------------- stopping */ + +/** + * End one member through its own engine, and write its end line. Returns + * what happened; never throws. A member the engine no longer has, with no end + * line from anyone, is `lost`, so the ledger closes rather than hangs. + */ +async function stopMember(m, fleetId, state, o) { + const { env, now, host } = o; + const by = env.OPENFLEET_MEMBER || "sysop"; + if (m.end && m.end.state !== "lost") return { member: m.member, outcome: "already-ended", state: m.end.state }; + const session = m.session || m.member; + let ended; + if (m.rosterOnly || /^moshcode\//.test(m.engine || "") || m.engine === "tmux") ended = await o.kill(session); + else if (m.engine === "claude-code") ended = o.exec("claude", ["stop", session]); + else if (m.engine === "claude-p") ended = Number.isInteger(Number(session)) ? o.signal(Number(session)) : { ok: false, error: "no pid in the record" }; + else ended = { ok: false, error: `no engine to stop it through (${m.engine || "unknown"})` }; + if (m.rosterOnly) return { member: m.member, outcome: ended.ok ? "stopped" : "failed", error: ended.error || null }; + if (ended.ok) { + fleet.append(fleetId, { event: "member.end", by, member: m.member, state }, { env, now: now(), host }); + return { member: m.member, outcome: "stopped", state }; + } + const listed = m.engine && /^moshcode\//.test(m.engine) ? o.roster().some((r) => r.name === session) : true; + if (!listed && !m.end) { + fleet.append(fleetId, { event: "member.end", by, member: m.member, state: "lost" }, { env, now: now(), host }); + return { member: m.member, outcome: "lost", state: "lost" }; + } + return { member: m.member, outcome: "failed", error: ended.error || "could not stop it" }; +} + +/** + * End a swarm as one unit (rule 11): nested swarms first, each with its own + * swarm.end, then the members through their engines, then this swarm's end + * line, written only when none exists. + */ +async function stopSwarm(s, fleetId, state, o, out) { + const { env, now, host } = o; + const by = env.OPENFLEET_MEMBER || "sysop"; + for (const m of s.members) for (const nested of m.swarms) await stopSwarm(nested, fleetId, state, o, out); + for (const m of s.members) out.members.push(await stopMember(m, fleetId, state, o)); + if (!fleet.hasEvent(fleetId, "swarm.end", { swarm: s.swarm }, env)) { + const lines = fleet.readLedger(fleetId, env); + const ends = s.members.map((m) => fleet.endOf(lines, m.member)).filter(Boolean); + const line = fleet.append(fleetId, { event: "swarm.end", by, swarm: s.swarm, state: fleet.swarmEndState(ends) }, { env, now: now(), host }); + out.swarms.push({ swarm: s.swarm, state: line?.state || null }); + } else { + out.swarms.push({ swarm: s.swarm, state: "already-ended" }); + } +} + +/** May an agent stop this target? Only a swarm it spawned, or anything under one. */ +function withinReach(caller, target, model) { + const swarms = new Map(); + const members = new Map(); + for (const f of model.fleets) { + for (const s of walkSwarms(f.nodes)) swarms.set(s.swarm, s); + for (const m of walkMembers(f.nodes)) members.set(m.member, m); + } + let swarm = target.kind === "swarm" ? target.node.swarm : target.node.swarm || null; + const seen = new Set(); + while (swarm && !seen.has(swarm)) { + seen.add(swarm); + const s = swarms.get(swarm); + if (!s) return false; + if (s.by === caller) return true; + swarm = members.get(s.by)?.swarm || null; + } + return false; +} + +function reportStops(out, { write, json }) { + if (json) { write(JSON.stringify(out, null, 2)); return; } + for (const m of out.members) { + if (m.outcome === "stopped") write(ok(`${bone(m.member)} stopped`)); + else if (m.outcome === "lost") write(warn(`${m.member} was already gone: marked lost`)); + else if (m.outcome === "already-ended") write(dim(`${m.member} had already ended (${m.state})`)); + else write(err(`${m.member}: ${m.error}`)); + } + for (const s of out.swarms) { + if (s.state === "already-ended") write(dim(`swarm ${s.swarm} had already ended`)); + else write(ok(`swarm ${bone(s.swarm)} ended ${s.state}`)); + } +} + +/* ------------------------------------------------------------------ verbs */ + +async function fleetOpen(argv, o) { + const { write, env, now, host } = o; + const refusal = agentRefusal(env, "open"); + if (refusal) { write(err(refusal)); return REFUSED; } + const { flags, positional, errors } = parseArgs(argv, { valued: ["approvals", "budget", "depth", "fan-out", "hosts", "until", "sysop", "name"], flags: ["json"] }); + const { ceiling, errors: bad } = ceilingFromFlags(flags, { now: now() }); + for (const e of [...errors, ...bad]) write(err(e)); + if (errors.length || bad.length) { write(err(USAGE.open)); return EXIT.usage; } + + // `-`, the form the spec recommends, and never a name an + // existing fleet already has on this box. + const stamp = fleet.iso(now()).slice(0, 10).replace(/-/g, ""); + const name = slugifyName(flags.name || positional[0] || "fleet"); + let id = `${name}-${stamp}`; + for (let n = 2; fleet.listFleets(env).includes(id); n++) id = `${name}-${stamp}-${n}`; + const sysop = flags.sysop || fleet.implicitFleet(env); + const line = fleet.append(id, { event: "fleet.open", by: "sysop", sysop, ceiling }, { env, now: now(), host }); + if (!line) { write(err(`could not write ${fleet.ledgerPath(id, env)}`)); return EXIT.infra; } + fleet.writeCurrent(id, env); + if (flags.json) { write(JSON.stringify({ fleet: id, sysop, ceiling, current: true, ledger: fleet.ledgerPath(id, env) }, null, 2)); return EXIT.matched; } + write(id); + write(info(`sysop ${sysop} · ceiling ${describeCeiling(ceiling)} · new root members join it: ${acid("moshcode fleet tree")}`)); + return EXIT.matched; +} + +async function fleetCap(argv, o) { + const { write, env, now, host } = o; + const refusal = agentRefusal(env, "cap"); + if (refusal) { write(err(refusal)); return REFUSED; } + const { flags, positional, errors } = parseArgs(argv, { valued: ["approvals", "budget", "depth", "fan-out", "hosts", "until", "fleet"], flags: ["json"] }); + const { ceiling, errors: bad } = ceilingFromFlags(flags, { now: now() }); + for (const e of [...errors, ...bad]) write(err(e)); + const target = positional[0]; + if (errors.length || bad.length || !target) { write(err(USAGE.cap)); return EXIT.usage; } + + const model = fleet.fold({ fleets: loadFleets(env, flags.fleet || null), roster: o.roster(), host, implicit: fleet.implicitFleet(env), now: now() }); + const found = locate(target, model, env); + if (!found || found.kind === "member") { write(err(`no fleet or swarm named ${JSON.stringify(target)}: ${acid("moshcode fleet tree")}`)); return EXIT.gone; } + const fleetId = found.fleet; + if (found.kind === "swarm") { + if (!Object.keys(ceiling).length) { write(err("cap on a swarm narrows at least one key")); write(err(USAGE.cap)); return EXIT.usage; } + const current = fleet.effectiveCeiling(fleet.readLedger(fleetId, env), fleetId, { swarm: target, host }); + if (!fleet.isNarrower(ceiling, current)) { + write(err(`cap narrows a running swarm and never widens it: ${describeCeiling(ceiling)} is not within ${describeCeiling(current)}`)); + return EXIT.usage; + } + } + fleet.append(fleetId, { event: "fleet.cap", by: "sysop", target, ceiling }, { env, now: now(), host }); + + // Members already above the new ceiling are stopped, each with its end line: + // a bypass member under a now-native ceiling, a member on a now-forbidden + // host, one deeper than the tree now allows. + const lines = fleet.readLedger(fleetId, env); + const fresh = fleet.fold({ fleets: [{ fleet: fleetId, lines, records: fleet.listRecords(fleetId, env) }], roster: o.roster(), host, implicit: fleet.implicitFleet(env), now: now() }); + const scope = found.kind === "fleet" ? fresh.fleets[0]?.nodes || [] : [[...walkSwarms(fresh.fleets[0]?.nodes || [])].find((s) => s.swarm === target)].filter(Boolean); + const out = { target, kind: found.kind, fleet: fleetId, ceiling, members: [], swarms: [] }; + for (const m of walkMembers(scope)) { + if (!m.claimed || m.end || m.rosterOnly) continue; + const effective = fleet.ceilingOf(fleetId, fleet.readMember(fleetId, m.member, env), { env, lines, swarm: m.swarm, host }); + const over = fleet.checkCeiling({ approvals: m.approvals, depth: m.depth, hosts: [m.host] }, effective, { now: now() }); + if (!over) continue; + const stopped = await stopMember(m, fleetId, "stopped", o); + out.members.push({ ...stopped, over: over.key }); + } + if (flags.json) { write(JSON.stringify(out, null, 2)); return EXIT.matched; } + write(ok(`${found.kind} ${bone(target)} capped: ${describeCeiling(ceiling)}`)); + reportStops(out, { write, json: false }); + return EXIT.matched; +} + +async function fleetTree(argv, o) { + const { write, env, now, host } = o; + const { flags, positional, errors } = parseArgs(argv, { valued: ["fleet"], flags: ["json"] }); + for (const e of errors) write(err(e)); + if (errors.length) { write(err(USAGE.tree)); return EXIT.usage; } + const only = flags.fleet || positional[0] || null; + const implicit = fleet.implicitFleet(env); + if (only && only !== implicit && !fleet.listFleets(env).includes(only)) { write(err(`no fleet named ${JSON.stringify(only)} under ${fleet.home(env)}`)); return EXIT.gone; } + const model = fleet.fold({ fleets: loadFleets(env, only), roster: o.roster(), host, implicit, now: now() }); + if (only) model.fleets = model.fleets.filter((f) => f.fleet === only); + // A recorded moshcode member the roster no longer lists, with no end line + // from anyone, is lost: the tool writes that line so the tree stops saying + // "running" about a pane that is not there (spec, `tree`). + const by = env.OPENFLEET_MEMBER || "sysop"; + for (const f of model.fleets) { + for (const m of walkMembers(f.nodes)) { + if (!m.lost) continue; + const line = fleet.append(f.fleet, { event: "member.end", by, member: m.member, state: "lost" }, { env, now: now(), host }); + if (line) m.end = line; + } + } + if (flags.json) { write(JSON.stringify(model, null, 2)); return EXIT.matched; } + if (!model.fleets.length) { + write(info(`no fleet yet under ${fleet.home(env)}: ${acid("moshcode fleet open")} opens one, ${acid("moshcode swarm")} records its members in the implicit fleet ${bone(implicit)}.`)); + return EXIT.matched; + } + for (const line of fleet.renderTree(model, { host }).split("\n")) write(line); + return EXIT.matched; +} + +async function fleetStop(argv, o) { + const { write, env } = o; + const { flags, positional, errors } = parseArgs(argv, { valued: ["fleet"], flags: ["json"] }); + for (const e of errors) write(err(e)); + const target = positional[0] || null; + if (errors.length || (!target && !flags.fleet)) { write(err(USAGE.stop)); return EXIT.usage; } + const caller = env.OPENFLEET_MEMBER || null; + const model = fleet.fold({ fleets: loadFleets(env), roster: o.roster(), host: o.host, implicit: fleet.implicitFleet(env), now: o.now() }); + const out = { target: target || flags.fleet, members: [], swarms: [] }; + + if (flags.fleet && !target) { + if (caller) { write(err(`stop --fleet is the sysop's: this process is member ${caller}, and an agent stops only the swarms it spawned.`)); return REFUSED; } + const f = model.fleets.find((x) => x.fleet === flags.fleet); + if (!f) { write(err(`no fleet named ${JSON.stringify(flags.fleet)}`)); return EXIT.gone; } + for (const n of f.nodes) { + if (n.kind === "swarm") await stopSwarm(n, f.fleet, "stopped", o, out); + else { + for (const s of n.swarms) await stopSwarm(s, f.fleet, "stopped", o, out); + out.members.push(await stopMember(n, f.fleet, "stopped", o)); + } + } + reportStops(out, { write, json: Boolean(flags.json) }); + return out.members.some((m) => m.outcome === "failed") ? EXIT.gone : EXIT.matched; + } + + const found = locate(target, model, env); + if (!found || found.kind === "fleet") { write(err(`no member or swarm named ${JSON.stringify(target)}: ${acid("moshcode fleet tree")}`)); return EXIT.gone; } + if (caller && !withinReach(caller, found, model)) { + write(err(`${target} is outside what member ${caller} spawned: an agent stops only a swarm it started, or a member under one.`)); + return REFUSED; + } + if (found.kind === "swarm") await stopSwarm(found.node, found.fleet, "stopped", o, out); + else { + for (const s of found.node.swarms) await stopSwarm(s, found.fleet, "stopped", o, out); + out.members.push(await stopMember(found.node, found.fleet, "stopped", o)); + } + reportStops(out, { write, json: Boolean(flags.json) }); + return out.members.some((m) => m.outcome === "failed") ? EXIT.gone : EXIT.matched; +} + +function describeLine(l) { + const c = (x) => describeCeiling(x || {}); + switch (l.event) { + case "fleet.open": return `${l.fleet} sysop ${l.sysop} · ${c(l.ceiling)}`; + case "fleet.cap": return `${l.target} · ${c(l.ceiling)}`; + case "swarm.spawn": return `${l.swarm} "${clip(l.task, 50)}" · ${(l.pieces || []).length} piece${(l.pieces || []).length === 1 ? "" : "s"}${Object.keys(l.ceiling || {}).length ? ` · ${c(l.ceiling)}` : ""}${l.parent_swarm ? ` · under ${l.parent_swarm}` : ""}`; + case "member.start": return `${l.member}${l.session && l.session !== l.member ? ` (${l.session})` : ""} · ${l.engine || "?"}${l.approvals === "bypass" ? " · bypass" : ""}${l.piece?.title ? ` · ${l.piece.title}` : ""}${l.piece?.owns?.length ? ` · owns ${l.piece.owns.join(",")}` : ""}`; + case "member.spend": return `${l.member} · ${l.amount}${l.total ? ` (total ${l.total})` : ""}`; + case "member.end": return `${l.member} · ${l.state}${l.total ? ` · ${l.total}` : ""}${l.summary ? ` · ${clip(String(l.summary).replace(/\s+/g, " "), 80)}` : ""}`; + case "swarm.end": return `${l.swarm} · ${l.state}${l.verdict ? ` · ${l.verdict.length} verdict${l.verdict.length === 1 ? "" : "s"}` : ""}${l.summary ? ` · ${clip(String(l.summary).replace(/\s+/g, " "), 80)}` : ""}`; + case "ceiling.refuse": return `${l.member ? `${l.member} · ` : ""}${l.action} refused on ${l.key}: wanted ${JSON.stringify(l.wanted)}, allowed ${JSON.stringify(l.allowed)}`; + default: return ""; + } +} + +const paintEvent = (event) => { + if (event === "ceiling.refuse") return amber(event); + if (event.endsWith(".end")) return ash(event); + if (event === "swarm.spawn" || event === "member.start") return acid(event); + return bone(event); +}; + +async function fleetLog(argv, o) { + const { write, env, now } = o; + const { flags, positional, errors } = parseArgs(argv, { valued: ["since", "member", "swarm", "fleet"], flags: ["json"] }); + for (const e of errors) write(err(e)); + if (errors.length) { write(err(USAGE.log)); return EXIT.usage; } + const only = flags.fleet || positional[0] || null; + let since = null; + if (flags.since !== undefined) { + const ms = parseDurationMs(flags.since); + since = ms !== null ? now() - ms : Date.parse(flags.since); + if (!Number.isFinite(since)) { write(err("--since is a duration like 1h, or an ISO 8601 time")); return EXIT.usage; } + } + const fleets = loadFleets(env, only); + // A member's end line names no swarm, so `--swarm` needs the membership + // the records and start lines hold. + const swarmOf = new Map(); + const lines = []; + for (const f of fleets) { + for (const r of f.records) if (r.member && r.swarm) swarmOf.set(r.member, r.swarm); + for (const l of f.lines) { + if (l.event === "member.start" && l.member && l.swarm) swarmOf.set(l.member, l.swarm); + lines.push(l); + } + } + lines.sort((a, b) => (Date.parse(a.at) || 0) - (Date.parse(b.at) || 0)); + const shown = lines.filter((l) => { + if (since !== null && (Date.parse(l.at) || 0) < since) return false; + if (flags.member && l.member !== flags.member && l.by !== flags.member) return false; + if (flags.swarm && l.swarm !== flags.swarm && l.target !== flags.swarm && swarmOf.get(l.member) !== flags.swarm) return false; + return true; + }); + if (flags.json) { for (const l of shown) write(JSON.stringify(l)); return EXIT.matched; } + if (!shown.length) { write(info("nothing in the ledger matches.")); return EXIT.matched; } + const manyFleets = new Set(shown.map((l) => l.fleet)).size > 1; + for (const l of shown) { + write(`${dim(l.at)} ${paintEvent(l.event).padEnd(14 + (paintEvent(l.event).length - l.event.length))} ${ash(`by ${l.by}`)} ${describeLine(l)}${manyFleets ? dim(` [${l.fleet}]`) : ""}`); + } + return EXIT.matched; +} + +/* --------------------------------------------------------------- dispatch */ + +const VERBS = { open: fleetOpen, cap: fleetCap, tree: fleetTree, stop: fleetStop, log: fleetLog }; + +export async function fleetCommand(argv = [], { write = console.log, ...deps } = {}) { + const o = { + write, env: process.env, now: () => Date.now(), host: fleet.host(), + roster: liveRoster, kill: liveKill, exec: liveExec, signal: liveSignal, + ...deps, + }; + const [verb, ...rest] = argv; + if (!verb || verb.startsWith("--")) return fleetTree(argv, o); + const run = VERBS[verb]; + if (!run) { + write(err(`unknown fleet verb ${JSON.stringify(verb)}`)); + write(info(`verbs: ${Object.keys(VERBS).join(", ")}`)); + return EXIT.usage; + } + return run(rest, o); +} diff --git a/src/herd-cli.mjs b/src/herd-cli.mjs index aa1b047a..5b16f91a 100644 --- a/src/herd-cli.mjs +++ b/src/herd-cli.mjs @@ -86,6 +86,27 @@ export function paintState(state) { */ export function renderRoster(rows, { indent = " " } = {}) { if (!rows.length) return ""; + const grouped = groupByFleet(rows); + // A roster with no fleet in it is the flat list it always was. One with a + // fleet is drawn as the tree the fleet is: the fleet, its swarms, and the + // members of each, so `moshcode ps` and `moshcode fleet tree` agree on who + // belongs to what (PRD 0016). + if (grouped.length === 1 && grouped[0].fleet === null && grouped[0].swarms.length === 1 && grouped[0].swarms[0].swarm === null) { + return rosterTable(rows, indent.length); + } + const out = []; + for (const group of grouped) { + if (group.fleet !== null) out.push(`${indent}${bone(group.fleet)} ${ash("fleet")}`); + for (const sub of group.swarms) { + const deeper = indent.length + (group.fleet !== null ? 2 : 0); + if (sub.swarm !== null) out.push(`${" ".repeat(deeper)}${bone(`swarm ${sub.swarm}`)}`); + out.push(rosterTable(sub.rows, deeper + (sub.swarm !== null ? 2 : 0))); + } + } + return out.join("\n"); +} + +function rosterTable(rows, indent) { // Cells go in painted and `table` measures what prints, which is what the // state column needed: padding a coloured string to a fixed 9 used to mean // hand-correcting the width by the length of its own escape codes, and the @@ -106,11 +127,36 @@ export function renderRoster(rows, { indent = " " } = {}) { // the hook install visible — and the one that stops a remote's claim from // being mistaken for something this box verified. dim(String(r.authority || "")), + // The mark the spec asks every sysop tool to show: this member runs with + // the engine's own approval prompts skipped. + r.approvals === "bypass" ? amber("bypass") : "", ]), - { columns: ["name", "engine", "state", "cwd", "age", "from"], header: false, indent: indent.length }, + { columns: ["name", "engine", "state", "cwd", "age", "from", "approvals"], header: false, indent }, ); } +/** + * Rows by fleet, then by swarm. Rows with no fleet come first under no + * heading, then each fleet in name order; inside a fleet, members outside any + * swarm come before the swarms. Shape: `[{ fleet, swarms: [{ swarm, rows }] }]`. + */ +export function groupByFleet(rows) { + const fleets = new Map(); + for (const row of rows) { + const fleet = row.fleet || null; + if (!fleets.has(fleet)) fleets.set(fleet, new Map()); + const swarms = fleets.get(fleet); + const swarm = row.swarm || null; + if (!swarms.has(swarm)) swarms.set(swarm, []); + swarms.get(swarm).push(row); + } + const order = (a, b) => (a === null ? -1 : b === null ? 1 : a.localeCompare(b)); + return [...fleets.entries()].sort(([a], [b]) => order(a, b)).map(([fleet, swarms]) => ({ + fleet, + swarms: [...swarms.entries()].sort(([a], [b]) => order(a, b)).map(([swarm, members]) => ({ swarm, rows: members })), + })); +} + /** Every session, with state attached. The one place that assembles both. */ export function roster(options = {}) { return withState(listSessions(options), options); @@ -142,12 +188,34 @@ function findSession(name, options) { * session that installed opencode — the exact case that bit the foreground * path first. */ -export function herdStart(argv, { write = console.log } = {}) { - const substrate = requireSubstrate(write); - if (!substrate) return EXIT.usage; +/** + * Does this launch skip the engine's own approval prompts? True for `--agent`, + * and true when the engine's autonomous flags are all present as plain args, + * which is how `moshcode swarm` starts its members. The manifest, the JSON and + * the warning all read this, so `sessions.json` cannot say `agent: false` + * about a pane running `claude --dangerously-skip-permissions` (PRD 0016). + */ +export function carriesBypass(engine, args = [], { agent = false } = {}) { + if (agent) return true; + const spec = typeof engine === "string" ? ENGINES[engine] : engine; + const flags = spec?.agentArgs || []; + return flags.length > 0 && flags.every((flag) => args.includes(flag)); +} - const flags = { name: null, cwd: process.cwd(), agent: false, json: false, herd: "main" }; +/** `herd start`'s own flags, split from the engine's. Exported for its tests. */ +export function parseStartArgs(argv = []) { + const flags = { name: null, cwd: process.cwd(), agent: false, json: false, herd: "main", env: {} }; const rest = []; + const errors = []; + // `--env KEY=VALUE`, repeatable: a variable for the session alone, set on + // the same prefix as MOSHCODE_HERD_NAME so both substrates and the strip + // treat it the same. A swarm hands its members their OPENFLEET_* this way. + const setEnv = (raw) => { + const eq = String(raw ?? "").indexOf("="); + const key = eq > 0 ? raw.slice(0, eq) : ""; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { errors.push(`--env takes KEY=VALUE, got ${JSON.stringify(String(raw ?? ""))}`); return; } + flags.env[key] = raw.slice(eq + 1); + }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--name") flags.name = argv[++i]; @@ -155,10 +223,30 @@ export function herdStart(argv, { write = console.log } = {}) { else if (a === "--cwd") flags.cwd = path.resolve(argv[++i] || "."); else if (a === "--herd") flags.herd = slugifyName(argv[++i]); else if (a.startsWith("--herd=")) flags.herd = slugifyName(a.slice(7)); + else if (a === "--env") setEnv(argv[++i]); + else if (a.startsWith("--env=")) setEnv(a.slice(6)); else if (a === "--agent") flags.agent = true; else if (a === "--json") flags.json = true; else rest.push(a); } + return { flags, rest, errors }; +} + +/** The fleet keys a member's env names, for the manifest entry. */ +function fleetMeta(env = {}) { + const meta = {}; + if (env.OPENFLEET_FLEET) meta.fleet = env.OPENFLEET_FLEET; + if (env.OPENFLEET_SWARM) meta.swarm = env.OPENFLEET_SWARM; + if (env.OPENFLEET_MEMBER) meta.member = env.OPENFLEET_MEMBER; + return meta; +} + +export function herdStart(argv, { write = console.log } = {}) { + const substrate = requireSubstrate(write); + if (!substrate) return EXIT.usage; + + const { flags, rest, errors } = parseStartArgs(argv); + if (errors.length) { for (const e of errors) write(err(e)); return EXIT.usage; } const target = rest.shift(); const resolved = target && resolveEngine(target); @@ -178,22 +266,31 @@ export function herdStart(argv, { write = console.log } = {}) { const bin = resolveExecutable(engine.bin, engine.binDirs || []) || engine.bin; const args = flags.agent ? agentLaunchArgs(engine, rest) : rest; + const bypass = carriesBypass(engine, args, { agent: flags.agent }); const started = startSession({ - name, engine: key, bin, args, stripEnv: engine.stripEnv || [], cwd: flags.cwd, substrate, + name, engine: key, bin, args, stripEnv: engine.stripEnv || [], cwd: flags.cwd, substrate, extraEnv: flags.env, }); if (!started.ok) { write(err(String(started.error?.message || started.error))); return EXIT.usage; } - rememberSession(name, { agent: flags.agent, herd: flags.herd }); + const fleet = fleetMeta(flags.env); + rememberSession(name, { + agent: bypass, approvals: bypass ? "bypass" : "native", herd: flags.herd, ...fleet, + // Kept so `herd restore` can hand the same variables back to the session + // it rebuilds. Paths and ids, never a credential: the manifest is 0600 + // regardless, because args already carry worse. + ...(Object.keys(flags.env).length ? { env: flags.env } : {}), + }); if (flags.json) { - write(JSON.stringify({ name, engine: key, herd: flags.herd, cwd: flags.cwd, substrate, agent: flags.agent }, null, 2)); + write(JSON.stringify({ name, engine: key, herd: flags.herd, cwd: flags.cwd, substrate, agent: bypass, approvals: bypass ? "bypass" : "native", ...fleet }, null, 2)); return EXIT.matched; } write(ok(`${bone(name)} — ${key} running in the herd. the prompt is yours.`)); - if (flags.agent) write(warn("agent mode: native approvals are bypassed or auto-approved.")); + if (bypass) write(warn("agent mode: native approvals are bypassed or auto-approved.")); + if (fleet.member) write(info(`fleet ${fleet.fleet}${fleet.swarm ? ` · swarm ${fleet.swarm}` : ""} · member ${fleet.member}`)); write(info(`workspace: ${acid("moshcode herd ui")} · attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`)); const note = substrateNote(substrate); if (note) write(info(note)); @@ -302,12 +399,15 @@ export function splitDetachArgs(args = []) { export function herdPs(argv, { write = console.log } = {}) { const rows = roster(); if (argv.includes("--json")) { - write(JSON.stringify(rows.map(({ name, engine, herd, state, authority, blockedOn, kind, url, cwd, age, alive, attached, substrate }) => ({ + write(JSON.stringify(rows.map(({ name, engine, herd, fleet, swarm, member, approvals, state, authority, blockedOn, kind, url, cwd, age, alive, attached, substrate }) => ({ name, engine, herd, state, authority, kind, ...(url ? { url } : {}), // The blocked sub-kind (R4) rides here and not in the roster's own // column: `--ask` needs to know whether a menu or a sentence is wanted, // and a person glancing at six rows does not. ...(blockedOn ? { blockedOn } : {}), + // Where the member sits in its fleet (PRD 0016), and whether it runs + // with the engine's approvals bypassed. Null when nobody said. + fleet: fleet || null, swarm: swarm || null, member: member || null, approvals: approvals || "native", cwd, ageMs: age, alive, attached, substrate, })), null, 2)); return EXIT.matched; @@ -1011,7 +1111,7 @@ export function herdRestore(argv, { write = console.log } = {}) { if (dryRun) { write(info(`would restore ${bone(name)} — ${meta.engine} in ${tilde(meta.cwd)}${resumeArgs ? " (resumed)" : ""}`)); restored++; continue; } const bin = resolveExecutable(engine.bin, engine.binDirs || []) || engine.bin; - const started = startSession({ name, engine: meta.engine, bin, args, stripEnv: engine.stripEnv || [], cwd: meta.cwd, substrate }); + const started = startSession({ name, engine: meta.engine, bin, args, stripEnv: engine.stripEnv || [], cwd: meta.cwd, substrate, extraEnv: meta.env || {} }); if (!started.ok) { write(err(`${name}: ${started.error?.message || started.error}`)); continue; } clearReport(name); write(ok(`${bone(name)} — ${meta.engine} in ${tilde(meta.cwd)}${resumeArgs ? ash(" (asked to resume)") : ""}`)); diff --git a/src/herd.mjs b/src/herd.mjs index 040308a2..764aa92d 100644 --- a/src/herd.mjs +++ b/src/herd.mjs @@ -412,7 +412,7 @@ export function sessionEnv(name) { return { MOSHCODE_HERD_NAME: name, MOSHCODE_HERD_DIR: herdDir() }; } -function ptyStart({ name, cwd, bin, args, stripEnv, env, spawner = spawn, runner = spawnSync, size = {} }) { +function ptyStart({ name, cwd, bin, args, stripEnv, env, extraEnv = {}, spawner = spawn, runner = spawnSync, size = {} }) { ensureDir(); const cols = Number(size.cols) || Number(env.COLUMNS) || process.stdout.columns || 80; const rows = Number(size.rows) || Number(env.LINES) || process.stdout.rows || 24; @@ -438,7 +438,7 @@ function ptyStart({ name, cwd, bin, args, stripEnv, env, spawner = spawn, runner // that a session which finishes on its own leaves proof it finished. const command = [ `stty rows ${rows} cols ${cols} 2>/dev/null`, - sessionCommand({ bin, args, stripEnv, setEnv: sessionEnv(name), exec: false }), + sessionCommand({ bin, args, stripEnv, setEnv: { ...sessionEnv(name), ...extraEnv }, exec: false }), `printf '%s' "$?" > ${shQuote(exit)}`, ].join("; "); // Reuse ptySpec's flag knowledge rather than re-deriving it: util-linux and @@ -457,7 +457,7 @@ function ptyStart({ name, cwd, bin, args, stripEnv, env, spawner = spawn, runner cwd, // Belt and braces with the stty above: some toolkits read COLUMNS/LINES // before they ever ask the terminal. - env: { ...env, COLUMNS: String(cols), LINES: String(rows), MOSHCODE_HERD_SESSION: name, ...sessionEnv(name) }, + env: { ...env, COLUMNS: String(cols), LINES: String(rows), MOSHCODE_HERD_SESSION: name, ...sessionEnv(name), ...extraEnv }, stdio: [stdin, "ignore", "ignore"], detached: true, }); @@ -655,6 +655,11 @@ export function startSession({ cwd = process.cwd(), substrate = detectSubstrate(), env = process.env, + // Variables set for this session beside its herd ones, on the same `env` + // prefix: a fleet's OPENFLEET_* (PRD 0016). They ride the prefix rather than + // process.env because tmux does not inherit the caller's environment, and + // they survive the strip because the strip is an explicit `-u` list. + extraEnv = {}, runner = spawnSync, spawner = spawn, } = {}) { @@ -675,7 +680,7 @@ export function startSession({ }; if (substrate === "tmux") { - const command = sessionCommand({ bin, args, stripEnv, setEnv: sessionEnv(name) }); + const command = sessionCommand({ bin, args, stripEnv, setEnv: { ...sessionEnv(name), ...extraEnv } }); let started = tmux(tmuxStartPlan({ name, cwd, command, pinTitle: tmuxCanPinTitle({ runner }) }), { runner, env }); if (!started.ok && UNKNOWN_PIN_OPTION.test(started.stderr || "")) { // The version guess was wrong (a distro build, a version string we did @@ -694,7 +699,7 @@ export function startSession({ return { ok: true, name, substrate }; } - const started = ptyStart({ name, cwd, bin, args, stripEnv, env, spawner, runner }); + const started = ptyStart({ name, cwd, bin, args, stripEnv, env, extraEnv, spawner, runner }); if (!started.ok) return started; rememberSession(name, { ...entry, pid: started.pid }); return { ok: true, name, substrate, pid: started.pid }; @@ -959,6 +964,14 @@ export function listSessions({ substrate = detectSubstrate(), runner = spawnSync // Sessions started before herds existed have none. They belong to `main` // rather than to a group rendered as "undefined". herd: meta.herd || "main", + // Where the member sits in its fleet (PRD 0016), when the starter said: + // a swarm names the fleet, the swarm and the member id it wrote the + // record under. `approvals` is truthful about the flags the engine + // runs with; entries older than the key fall back to the `agent` mark. + fleet: meta.fleet || null, + swarm: meta.swarm || null, + member: meta.member || null, + approvals: meta.approvals || (meta.agent ? "bypass" : "native"), cwd: meta.cwd || "", created: meta.created || null, age: meta.created ? now - meta.created : null, diff --git a/src/openfleet.mjs b/src/openfleet.mjs new file mode 100644 index 00000000..2abdce2c --- /dev/null +++ b/src/openfleet.mjs @@ -0,0 +1,736 @@ +// OpenFleet (logicsrc.com/docs/openfleet, 0.1): the record an agent session +// carries about where it sits, and the ledger a human reads it from. +// +// This is the minimal IO moshcode needs as a member engine and a sysop tool: +// one JSON record per member under `$OPENFLEET_HOME/fleets//members/`, +// one append-only ledger per fleet per host beside it, the ceiling merge, and +// the fold that turns both into the tree `moshcode fleet tree` draws. Nothing +// here talks to tmux or a model; swarm.mjs and fleet-cli.mjs do that. +// +// `$OPENFLEET_HOME` is read on every call, like herdDir(), so a test isolates +// itself with a mkdtemp and one env var. Files are 0600 and directories 0700: +// a record names a working directory and a ledger names what an agent tried, +// and neither is anyone else's business on a shared box. Neither ever holds a +// credential. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { slugifyName } from "./herd.mjs"; + +export const OPENFLEET_VERSION = "0.1"; + +/** The keys a ceiling may carry, in the order the spec lists them. */ +export const CEILING_KEYS = ["approvals", "budget", "depth", "fan_out", "hosts", "until"]; + +/** How a member may end. One end line counts, except `lost`, which a real one supersedes. */ +export const END_STATES = ["done", "failed", "stopped", "budget", "timeout", "lost"]; + +/** The engine strings the spec names. moshcode writes the `moshcode/` ones. */ +export const ENGINE_STRINGS = ["claude-code", "moshcode/claude", "moshcode/codex", "moshcode/deepseek", "moshcode/kimi", "claude-p", "tmux"]; + +/* ------------------------------------------------------------ where it lives */ + +export function home(env = process.env) { + return env.OPENFLEET_HOME || path.join(os.homedir(), ".openfleet"); +} + +export function host() { + return os.hostname(); +} + +function username(env = process.env) { + try { return os.userInfo().username; } + catch { return env.USER || env.USERNAME || "user"; } +} + +/** `@`: the fleet a member belongs to when none was opened, and its sysop. */ +export function implicitFleet(env = process.env) { + return `${username(env)}@${host()}`; +} + +/** The implicit fleet's ceiling: depth 1, this host, and no fleet-level approvals. */ +export function implicitCeiling() { + return { depth: 1, hosts: [host()] }; +} + +/** ISO 8601 UTC to the second, the way every example in the spec is written. */ +export function iso(ms = Date.now()) { + return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +export function fleetsDir(env = process.env) { + return path.join(home(env), "fleets"); +} + +export function fleetDir(fleet, env = process.env) { + return path.join(fleetsDir(env), String(fleet)); +} + +export function recordPath(fleet, member, env = process.env) { + return path.join(fleetDir(fleet, env), "members", `${member}.json`); +} + +export function ledgerPath(fleet, env = process.env) { + return path.join(fleetDir(fleet, env), "ledger.jsonl"); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); +} + +/** Every fleet with a directory under the home, opened or implicit. */ +export function listFleets(env = process.env) { + try { + return fs.readdirSync(fleetsDir(env), { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + } catch { return []; } +} + +/** The fleet the account's next root member joins, from `$OPENFLEET_HOME/current`. */ +export function currentFleet(env = process.env) { + try { + const value = fs.readFileSync(path.join(home(env), "current"), "utf8").trim(); + return value || null; + } catch { return null; } +} + +export function writeCurrent(fleet, env = process.env) { + try { + ensureDir(home(env)); + const file = path.join(home(env), "current"); + fs.writeFileSync(file, `${fleet}\n`, { mode: 0o600 }); + fs.chmodSync(file, 0o600); + return true; + } catch { return false; } +} + +/* ---------------------------------------------------------------- the record */ + +/** + * Write a member's record. Refuses to overwrite: the record is written once, + * before the member starts, and never changes after `member.start` (rule 7). + * `wx` makes the refusal atomic rather than a check followed by a write. + */ +export function writeRecord(record, { env = process.env } = {}) { + const file = recordPath(record.fleet, record.member, env); + try { + ensureDir(path.dirname(file)); + fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 }); + fs.chmodSync(file, 0o600); + return { ok: true, path: file }; + } catch (error) { + return { ok: false, path: file, error }; + } +} + +/** A record by path. Null when absent or unreadable; unknown keys are kept. */ +export function readRecord(file) { + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch { return null; } +} + +export function readMember(fleet, member, env = process.env) { + return readRecord(recordPath(fleet, member, env)); +} + +/** Every record under a fleet, in file order. */ +export function listRecords(fleet, env = process.env) { + const dir = path.join(fleetDir(fleet, env), "members"); + let names; + try { names = fs.readdirSync(dir).filter((n) => n.endsWith(".json")).sort(); } + catch { return []; } + return names.map((n) => readRecord(path.join(dir, n))).filter(Boolean); +} + +/* ---------------------------------------------------------------- the ledger */ + +/** + * Every ledger under a fleet: this host's `ledger.jsonl` and any + * `ledger..jsonl` copied in from another. Readers merge them by `at`. + */ +export function ledgerPaths(fleet, env = process.env) { + const dir = fleetDir(fleet, env); + try { + return fs.readdirSync(dir) + .filter((n) => /^ledger(\.[^/]+)?\.jsonl$/.test(n)) + .sort() + .map((n) => path.join(dir, n)); + } catch { return []; } +} + +/** + * Append one line to this host's ledger. Adds `at`, `fleet` and `host`; the + * caller passes `event`, `by` and the event's own keys. Never throws: a lost + * ledger line must not fail the swarm that was writing it. Returns the line as + * written, or null. + */ +export function append(fleet, line, { env = process.env, now = Date.now(), host: h = host() } = {}) { + const { at, event, by, ...rest } = line || {}; + const written = { at: at || iso(now), event, fleet, host: h, by, ...rest }; + try { + ensureDir(fleetDir(fleet, env)); + const file = ledgerPath(fleet, env); + fs.appendFileSync(file, `${JSON.stringify(written)}\n`, { mode: 0o600 }); + fs.chmodSync(file, 0o600); + return written; + } catch { return null; } +} + +const atMs = (line) => { + const t = Date.parse(line?.at || ""); + return Number.isFinite(t) ? t : 0; +}; + +/** Every line of every ledger under a fleet, merged and sorted by `at` (stable). */ +export function readLedger(fleet, env = process.env) { + const lines = []; + for (const file of ledgerPaths(fleet, env)) { + let text; + try { text = fs.readFileSync(file, "utf8"); } + catch { continue; } + for (const raw of text.split("\n")) { + if (!raw.trim()) continue; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && parsed.event) lines.push(parsed); + } catch { /* a torn line is skipped, never fatal */ } + } + } + return lines + .map((line, i) => ({ line, i })) + .sort((a, b) => atMs(a.line) - atMs(b.line) || a.i - b.i) + .map(({ line }) => line); +} + +/** The lines of one event whose keys match `where`. */ +export function findEvents(lines, event, where = {}) { + return (lines || []).filter((l) => l.event === event && Object.entries(where).every(([k, v]) => l[k] === v)); +} + +/** Whether the fleet's ledger holds an event matching `where`. */ +export function hasEvent(fleet, event, where = {}, env = process.env) { + return findEvents(readLedger(fleet, env), event, where).length > 0; +} + +/** The `member.start` that claimed a record, or null when it is unclaimed. */ +export function claimedBy(lines, member) { + return findEvents(lines, "member.start", { member })[0] || null; +} + +/** + * The end line that counts for a member: the first written, except `lost`, + * which the engine's or the spawner's own end supersedes whenever it arrives. + */ +export function endOf(lines, member) { + const ends = findEvents(lines, "member.end", { member }); + return ends.find((l) => l.state !== "lost") || ends[0] || null; +} + +/** + * A swarm's end state from its members' end lines: `done` when every member + * ended `done`, else the first of failed, stopped, budget, timeout found among + * them. Members that only went `lost`, or no members at all, are a failure. + */ +export function swarmEndState(ends) { + const states = (ends || []).map((l) => (typeof l === "string" ? l : l?.state)).filter(Boolean); + if (states.length && states.every((s) => s === "done")) return "done"; + return states.find((s) => ["failed", "stopped", "budget", "timeout"].includes(s)) || "failed"; +} + +/* --------------------------------------------------------------- the ceiling */ + +/** `"20 USD"` or `"5000 tokens"` as a number and its unit, or null. */ +export function parseBudget(value) { + const m = /^\s*([0-9]+(?:\.[0-9]+)?)\s+(\S+)\s*$/.exec(String(value ?? "")); + return m ? { amount: Number(m[1]), unit: m[2] } : null; +} + +/** Key by key: every key `narrowing` carries replaces the one in `base`. */ +export function mergeCeiling(base = {}, narrowing = {}) { + const out = { ...(base || {}) }; + for (const key of CEILING_KEYS) { + if (narrowing && narrowing[key] !== undefined && narrowing[key] !== null) out[key] = narrowing[key]; + } + return out; +} + +const untilMs = (value) => { + const t = Date.parse(String(value ?? "")); + return Number.isFinite(t) ? t : null; +}; + +/** + * Is one key of `narrowing` narrower than, or equal to, the same key of `base`? + * Absent in base means uncapped for budget, fan_out, hosts and until; native + * for approvals; 1 for depth. + */ +function keyNarrower(key, wanted, base) { + if (wanted === undefined || wanted === null) return true; + const allowed = base?.[key]; + switch (key) { + case "approvals": + return wanted === "native" || allowed === "bypass"; + case "budget": { + if (allowed === undefined) return true; + const w = parseBudget(wanted), a = parseBudget(allowed); + return Boolean(w && a && w.unit === a.unit && w.amount <= a.amount); + } + case "depth": + return Number(wanted) <= Number(allowed ?? 1); + case "fan_out": + return allowed === undefined || Number(wanted) <= Number(allowed); + case "hosts": + return !Array.isArray(allowed) || (Array.isArray(wanted) && wanted.every((h) => allowed.includes(h))); + case "until": { + if (allowed === undefined) return true; + const w = untilMs(wanted), a = untilMs(allowed); + return w !== null && a !== null && w <= a; + } + default: + return true; + } +} + +/** Every key `narrowing` carries is narrower than, or equal to, `base`'s. */ +export function isNarrower(narrowing = {}, base = {}) { + return CEILING_KEYS.every((key) => keyNarrower(key, narrowing?.[key], base)); +} + +/** + * The keys of `wanted` that actually narrow `base`: what a spawner writes in + * its `swarm.spawn`. A key that would widen, or that equals what is inherited, + * is left out, because a spawner never widens and an inherited key is not + * written twice. + */ +export function narrowingOf(wanted = {}, base = {}) { + const out = {}; + for (const key of CEILING_KEYS) { + const value = wanted?.[key]; + if (value === undefined || value === null) continue; + if (!keyNarrower(key, value, base)) continue; + const inherited = base?.[key]; + if (inherited !== undefined && JSON.stringify(inherited) === JSON.stringify(value)) continue; + out[key] = value; + } + return out; +} + +/** + * Would `wanted` exceed `allowed`? The first key that does, as + * `{ key, wanted, allowed }` for a `ceiling.refuse` line, or null. + * + * `wanted` carries what a member or a swarm asks for: `approvals`, `depth`, + * `fan_out` (how many members the swarm holds), `hosts` (where they run), + * `until` (a deadline it would set) and `budget`. `now` is checked against an + * `until` already in force. An `approvals` key absent from `allowed` is + * unconstrained here; whoever built `allowed` resolves the absent-means-native + * rule for an opened fleet (see fleetCeiling). + */ +export function checkCeiling(wanted = {}, allowed = {}, { now = Date.now() } = {}) { + const w = wanted || {}, a = allowed || {}; + if (w.approvals === "bypass" && a.approvals !== undefined && a.approvals !== "bypass") { + return { key: "approvals", wanted: "bypass", allowed: a.approvals }; + } + if (w.depth !== undefined && Number(w.depth) > Number(a.depth ?? 1)) { + return { key: "depth", wanted: Number(w.depth), allowed: Number(a.depth ?? 1) }; + } + if (w.fan_out !== undefined && a.fan_out !== undefined && Number(w.fan_out) > Number(a.fan_out)) { + return { key: "fan_out", wanted: Number(w.fan_out), allowed: Number(a.fan_out) }; + } + const hosts = Array.isArray(w.hosts) ? w.hosts : w.host ? [w.host] : null; + if (hosts && Array.isArray(a.hosts) && hosts.some((h) => !a.hosts.includes(h))) { + return { key: "hosts", wanted: hosts, allowed: a.hosts }; + } + if (a.until !== undefined) { + const deadline = untilMs(a.until); + if (deadline !== null && now >= deadline) return { key: "until", wanted: iso(now), allowed: a.until }; + const asked = untilMs(w.until); + if (deadline !== null && asked !== null && asked > deadline) return { key: "until", wanted: w.until, allowed: a.until }; + } + if (w.budget !== undefined && a.budget !== undefined) { + const wb = parseBudget(w.budget), ab = parseBudget(a.budget); + if (wb && ab && wb.unit === ab.unit && wb.amount > ab.amount) return { key: "budget", wanted: w.budget, allowed: a.budget }; + } + return null; +} + +/** + * A fleet's whole ceiling: the latest `fleet.cap` whose target is the fleet, + * else `fleet.open`, else the implicit fleet's. An opened fleet's absent keys + * are resolved the way the spec reads them (approvals native, depth 1, hosts + * the host the line was written on); the implicit fleet carries no approvals + * key at all, because each root member supplies its own. + */ +export function fleetCeiling(lines, fleet, { host: h = host() } = {}) { + const caps = findEvents(lines, "fleet.cap", { target: fleet }); + const open = findEvents(lines, "fleet.open"); + const line = caps.at(-1) || open.at(-1) || null; + if (!line) return { ceiling: { depth: 1, hosts: [h] }, opened: false, line: null }; + const c = line.ceiling || {}; + return { + ceiling: { ...c, approvals: c.approvals || "native", depth: c.depth ?? 1, hosts: Array.isArray(c.hosts) ? c.hosts : [line.host || h] }, + opened: true, + line, + }; +} + +/** The `swarm.spawn` lines from the top-most ancestor down to `swarm`. */ +export function swarmChain(lines, swarm) { + const spawns = new Map(findEvents(lines, "swarm.spawn").map((l) => [l.swarm, l])); + const chain = []; + const seen = new Set(); + let id = swarm; + while (id && spawns.has(id) && !seen.has(id)) { + seen.add(id); + const spawn = spawns.get(id); + chain.unshift(spawn); + id = spawn.parent_swarm || null; + } + return chain; +} + +/** The record at the top of a member's parent chain, or the deepest one readable. */ +export function rootOf(fleet, record, env = process.env) { + let current = record; + const seen = new Set(); + while (current?.parent && !seen.has(current.parent)) { + seen.add(current.parent); + const parent = readMember(fleet, current.parent, env); + if (!parent) break; + current = parent; + } + return current || null; +} + +/** + * The effective ceiling at a point in the tree: the fleet's, merged key by key + * with each `swarm.spawn` on the path down to `swarm`, with the latest + * `fleet.cap` for any swarm on that path applied after its spawn. In the + * implicit fleet `approvals` enters at the root: `rootApprovals`, or the + * record's own ceiling when the caller has one and no chain to rebuild from. + */ +export function effectiveCeiling(lines, fleet, { swarm = null, rootApprovals = null, record = null, host: h = host() } = {}) { + const { ceiling: base, opened } = fleetCeiling(lines, fleet, { host: h }); + let ceiling = { ...base }; + if (!opened) { + if (record?.ceiling) ceiling = { ...record.ceiling }; + if (rootApprovals) ceiling.approvals = rootApprovals; + } + for (const spawn of swarmChain(lines, swarm)) { + ceiling = mergeCeiling(ceiling, spawn.ceiling || {}); + const cap = findEvents(lines, "fleet.cap", { target: spawn.swarm }).at(-1); + if (cap) ceiling = mergeCeiling(ceiling, cap.ceiling || {}); + } + return ceiling; +} + +/** + * The effective ceiling a member's record was, or would be, started under: + * effectiveCeiling with the implicit fleet's root approvals resolved. In the + * implicit fleet approvals enter at the root: when the walk up the parent + * chain reaches it, its own flags decide; when a record on the way up is + * missing, the ceiling copied into this record is the best remaining witness. + */ +export function ceilingOf(fleet, record, { env = process.env, lines = readLedger(fleet, env), swarm = record?.swarm || null, host: h = host() } = {}) { + const { opened } = fleetCeiling(lines, fleet, { host: h }); + let rootApprovals = null; + if (record && !opened) { + const root = rootOf(fleet, record, env); + rootApprovals = root && !root.parent + ? (root.orphan ? "native" : root.approvals || "native") + : record.ceiling?.approvals || record.approvals || "native"; + } + return effectiveCeiling(lines, fleet, { swarm, rootApprovals, record, host: h }); +} + +/* ---------------------------------------------------------------- the caller */ + +/** + * Where this process sits: its own record from `OPENFLEET_RECORD`, else the + * fleet `OPENFLEET_FLEET` names, else `current`, else the implicit fleet. + * `agent` is the test the human-only verbs make: `OPENFLEET_MEMBER` present. + */ +export function context(env = process.env) { + const recordFile = env.OPENFLEET_RECORD || null; + const record = recordFile ? readRecord(recordFile) : null; + const fleet = record?.fleet || env.OPENFLEET_FLEET || currentFleet(env) || implicitFleet(env); + const lines = readLedger(fleet, env); + const { opened, line: opener } = fleetCeiling(lines, fleet); + const sysop = record?.sysop || opener?.sysop || implicitFleet(env); + const member = record?.member || env.OPENFLEET_MEMBER || null; + const swarm = record?.swarm || env.OPENFLEET_SWARM || null; + const ceiling = ceilingOf(fleet, record, { env, lines, swarm }); + return { + home: home(env), + fleet, + sysop, + opened, + record, + recordPath: recordFile, + member, + swarm, + depth: Number(record?.depth ?? 0), + ceiling, + agent: Boolean(env.OPENFLEET_MEMBER), + by: member || "sysop", + }; +} + +/* ------------------------------------------------------------------ the ids */ + +/** + * `-`, the form + * the spec recommends: `create-two-0541`. Short enough that `-` still + * fits the herd's NAME_RE, since the member id is also the pane name. + */ +export function swarmId(task, { name = null, now = Date.now() } = {}) { + const slug = slugifyName(name || task).slice(0, 23).replace(/-+$/, "") || "task"; + const d = new Date(now); + const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}${String(d.getUTCMinutes()).padStart(2, "0")}`; + return `${slug}-${hhmm}`; +} + +/* ----------------------------------------------------------------- the fold */ + +/** Sum spend strings of matching units; "12 USD + 5000 tokens" when they differ. */ +export function sumSpend(values) { + const totals = new Map(); + for (const v of values) { + const b = parseBudget(v); + if (!b) continue; + totals.set(b.unit, (totals.get(b.unit) || 0) + b.amount); + } + return totals.size ? [...totals.entries()].map(([unit, amount]) => `${Number(amount.toFixed(6))} ${unit}`).join(" + ") : null; +} + +/** + * Records and ledgers, folded into the tree a sysop tool draws. + * + * `fleets` is `[{ fleet, lines, records }]`; `roster` is the herd's roster + * (`[{ name, engine, state, alive, approvals, cwd }]`, engine already spelled + * `moshcode/`) or null when none was read. With a roster, a recorded + * moshcode member the roster no longer lists reads `lost` and is flagged so the + * caller can write that end line, and a roster session with no record is drawn + * as a root member of the implicit fleet marked as coming from the roster. + * + * Swarms hang under the member that spawned them, or under the fleet when the + * sysop started one by hand; members hang under their swarm. + */ +export function fold({ fleets = [], roster = null, host: h = host(), implicit = implicitFleet(), now = Date.now() } = {}) { + const byFleet = new Map(); + for (const f of fleets) byFleet.set(f.fleet, f); + + const matched = new Set(); + const out = []; + for (const { fleet, lines = [], records = [] } of byFleet.values()) { + const open = findEvents(lines, "fleet.open").at(-1) || null; + const { ceiling } = fleetCeiling(lines, fleet, { host: h }); + const sysop = open?.sysop || records.find((r) => r.sysop)?.sysop || fleet; + + const spawns = new Map(); + for (const l of findEvents(lines, "swarm.spawn")) if (!spawns.has(l.swarm)) spawns.set(l.swarm, l); + const swarmEnds = new Map(); + for (const l of findEvents(lines, "swarm.end")) if (!swarmEnds.has(l.swarm)) swarmEnds.set(l.swarm, l); + const starts = new Map(); + for (const l of findEvents(lines, "member.start")) if (!starts.has(l.member)) starts.set(l.member, l); + const spends = new Map(); + for (const l of findEvents(lines, "member.spend")) if (l.total) spends.set(l.member, l.total); + + const members = new Map(); + const node = (r, s) => { + const id = r?.member || s?.member; + const end = endOf(lines, id); + const start = s || starts.get(id) || null; + return { + kind: "member", + member: id, + session: r?.session || start?.session || null, + title: r?.piece?.title || start?.piece?.title || null, + task: r?.task || null, + engine: r?.engine || start?.engine || null, + host: r?.host || start?.host || h, + cwd: r?.cwd || start?.cwd || null, + depth: Number(r?.depth ?? start?.depth ?? 0), + parent: r?.parent || start?.parent || null, + swarm: r?.swarm || start?.swarm || null, + approvals: r?.approvals || start?.approvals || "native", + owns: r?.piece?.owns || start?.piece?.owns || null, + orphan: Boolean(r?.orphan), + recorded: Boolean(r), + claimed: Boolean(start), + started: start?.at || r?.started || null, + end, + state: end ? end.state : start ? "running" : "unclaimed", + spend: spends.get(id) || null, + live: null, + lost: false, + rosterOnly: false, + swarms: [], + }; + }; + for (const r of records) if (r?.member && !members.has(r.member)) members.set(r.member, node(r, null)); + for (const [id, s] of starts) if (!members.has(id)) members.set(id, node(null, s)); + + if (roster) { + for (const m of members.values()) { + if (!(/^moshcode\//.test(m.engine || "") || m.engine === "tmux")) continue; + const row = roster.find((x) => x.name === m.session || x.name === m.member); + if (row) { + matched.add(row.name); + m.live = Boolean(row.alive); + if (!m.end && m.claimed && row.state) m.state = row.state; + } else if (m.claimed && !m.end) { + m.state = "lost"; + m.lost = true; + } + } + } + + const swarmNodes = new Map(); + for (const [id, spawn] of spawns) { + const effective = effectiveCeiling(lines, fleet, { swarm: id, host: h }); + const end = swarmEnds.get(id) || null; + swarmNodes.set(id, { + kind: "swarm", swarm: id, task: spawn.task || null, by: spawn.by || null, parent_swarm: spawn.parent_swarm || null, + ceiling: spawn.ceiling || {}, fan_out: effective.fan_out ?? null, until: effective.until ?? null, + pieces: Array.isArray(spawn.pieces) ? spawn.pieces.length : 0, + end, state: end ? end.state : "running", at: spawn.at || null, missing: false, members: [], spend: null, + }); + } + for (const m of members.values()) { + if (m.swarm && !swarmNodes.has(m.swarm)) { + swarmNodes.set(m.swarm, { + kind: "swarm", swarm: m.swarm, task: m.task, by: m.parent || null, parent_swarm: null, ceiling: {}, fan_out: null, until: null, + pieces: 0, end: swarmEnds.get(m.swarm) || null, state: swarmEnds.get(m.swarm)?.state || "running", at: null, missing: true, members: [], spend: null, + }); + } + } + // Dated nodes in time order, undated ones (a swarm nobody spawned in this + // ledger) after them, ties by id. + const stamp = (n) => String(n.started || n.at || "\uffff"); + const byStart = (a, b) => stamp(a).localeCompare(stamp(b)) || String(a.member || a.swarm).localeCompare(String(b.member || b.swarm)); + for (const s of swarmNodes.values()) { + const order = (spawns.get(s.swarm)?.pieces || []).map((p) => p.member); + s.members = [...members.values()].filter((m) => m.swarm === s.swarm) + .sort((a, b) => { + const ia = order.indexOf(a.member), ib = order.indexOf(b.member); + if (ia >= 0 || ib >= 0) return (ia < 0 ? Infinity : ia) - (ib < 0 ? Infinity : ib); + return byStart(a, b); + }); + s.spend = sumSpend(s.members.map((m) => m.spend).filter(Boolean)); + if (!s.end && s.members.length && s.members.every((m) => m.end)) s.state = "ended"; + } + const top = []; + for (const s of [...swarmNodes.values()].sort(byStart)) { + const spawner = s.by && s.by !== "sysop" ? members.get(s.by) : null; + if (spawner) spawner.swarms.push(s); + else top.push(s); + } + const roots = [...members.values()].filter((m) => !m.swarm).sort(byStart); + out.push({ + fleet, sysop, implicit: !open, ceiling, + nodes: [...roots, ...top], + refusals: findEvents(lines, "ceiling.refuse"), + spend: sumSpend([...members.values()].map((m) => m.spend).filter(Boolean)), + budget: ceiling.budget ?? null, + }); + } + + // A roster session with no record is a root member of the implicit fleet, + // which exists on disk only once something recorded is in it; here it is + // drawn either way, so an unaware engine still renders as a tree (rule 12). + const unrecorded = (roster || []).filter((row) => !matched.has(row.name)); + if (unrecorded.length) { + let impl = out.find((f) => f.fleet === implicit); + if (!impl) { + impl = { fleet: implicit, sysop: implicit, implicit: true, ceiling: { depth: 1, hosts: [h] }, nodes: [], refusals: [], spend: null, budget: null }; + out.push(impl); + } + for (const row of unrecorded) { + impl.nodes.push({ + kind: "member", member: row.name, session: row.name, title: null, task: null, engine: row.engine || null, host: h, cwd: row.cwd || null, + depth: 0, parent: null, swarm: null, approvals: row.approvals || "native", owns: null, orphan: false, recorded: false, claimed: false, + started: null, end: null, state: row.state || (row.alive ? "running" : "gone"), spend: null, live: Boolean(row.alive), lost: false, rosterOnly: true, swarms: [], + }); + } + } + + out.sort((a, b) => (a.fleet === implicit ? -1 : b.fleet === implicit ? 1 : a.fleet.localeCompare(b.fleet))); + return { at: iso(now), host: h, fleets: out }; +} + +/* --------------------------------------------------------------- the render */ + +const hhmm = (value) => { + const t = untilMs(value); + if (t === null) return String(value); + const d = new Date(t); + return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; +}; + +const clipText = (s, n) => (String(s).length > n ? `${String(s).slice(0, n - 3)}...` : String(s)); + +function memberLabel(m, h) { + const parts = [m.session && m.session !== m.member ? `${m.member} (${m.session})` : m.member]; + if (m.title) parts.push(m.title); + else if (m.rosterOnly && m.cwd) parts.push(m.cwd); + if (m.engine) parts.push(m.engine); + if (m.host && m.host !== h) parts.push(`@${m.host}`); + parts.push(m.state); + if (m.approvals === "bypass") parts.push("[bypass]"); + if (Array.isArray(m.owns) && m.owns.length) parts.push(`owns ${m.owns.join(",")}`); + if (m.orphan) parts.push("[orphan]"); + if (m.rosterOnly) parts.push("[roster]"); + if (m.spend) parts.push(`spent ${m.spend}`); + return parts.join(" "); +} + +function swarmLabel(s) { + const parts = [`swarm ${s.swarm}`]; + if (s.task) parts.push(`"${clipText(s.task, 40)}"`); + parts.push(`${s.members.length}${s.fan_out ? `/${s.fan_out}` : ""} member${s.members.length === 1 ? "" : "s"}`); + if (s.end) parts.push(s.end.state); + else if (s.state === "ended") parts.push("ended"); + else if (s.until) parts.push(`until ${hhmm(s.until)}`); + else parts.push("running"); + if (s.spend) parts.push(`spent ${s.spend}`); + if (s.missing) parts.push("[no swarm.spawn]"); + return parts.join(" "); +} + +function fleetLabel(f) { + const c = f.ceiling || {}; + const bits = [f.implicit ? "implicit fleet" : "fleet", `sysop ${f.sysop}`]; + if (c.approvals) bits.push(`approvals ${c.approvals}`); + bits.push(`depth ${c.depth ?? 1}`); + if (c.fan_out) bits.push(`fan_out ${c.fan_out}`); + if (Array.isArray(c.hosts)) bits.push(`hosts ${c.hosts.join(",")}`); + if (c.until) bits.push(`until ${hhmm(c.until)}`); + if (c.budget) bits.push(`${f.spend ? `spent ${f.spend} of ` : "budget "}${c.budget}`); + else if (f.spend) bits.push(`spent ${f.spend}`); + return `${f.fleet} (${bits.join(", ")})`; +} + +/** The tree as text, in the shape of the spec's landing page. */ +export function renderTree(model, { host: h = host() } = {}) { + const lines = []; + const draw = (nodes, prefix) => { + nodes.forEach((n, i) => { + const last = i === nodes.length - 1; + lines.push(`${prefix}${last ? "└─ " : "├─ "}${n.kind === "swarm" ? swarmLabel(n) : memberLabel(n, h)}`); + const children = n.kind === "swarm" ? n.members : n.swarms; + if (children?.length) draw(children, `${prefix}${last ? " " : "│ "}`); + }); + }; + for (const f of model.fleets || []) { + lines.push(fleetLabel(f)); + if (f.nodes?.length) draw(f.nodes, ""); + else lines.push("└─ (no members yet)"); + } + return lines.join("\n"); +} diff --git a/src/swarm.mjs b/src/swarm.mjs index 4e0ee3d1..ff402e7d 100644 --- a/src/swarm.mjs +++ b/src/swarm.mjs @@ -1,4 +1,4 @@ -// Swarm — one task, a herd of agents, one answer (PRD 0015). +// Swarm — one task, a herd of agents, one answer (PRD 0015, PRD 0016). // // Claude Code calls it ultracode: a prompt that becomes a workflow of agents, // planned, fanned out, verified and synthesised. The herd already has every @@ -23,20 +23,31 @@ // synthesis sees both and the operator decides. // synthesise one more headless call folds the pieces into an answer. // +// AND THE RECORD OF IT (OpenFleet, PRD 0016). A swarm is minted before the +// plan call, checked against the ceiling it runs under before anything is +// written, announced with one `swarm.spawn` before the first member starts, +// and every member carries a record file and the four OPENFLEET_* variables +// beside the herd's own. Each pane is named after its member id. When the +// swarm is done, moshcode writes `member.end` for every pane that did not +// write its own, then one `swarm.end` carrying the synthesis, and only then +// kills the sessions, so the ledger never says a member ended after its +// engine had already forgotten it. +// // The sessions are ended when the swarm is done unless `--keep` says // otherwise: four idle engines per swarm would fill the roster by lunchtime, // and the ledger keeps what they did either way (`moshcode herd task `). // -// Everything that talks to an engine or a pty goes through `deps`, so the -// orchestration is testable without tmux or a model on the box. +// Everything that talks to an engine, a pty or the fleet's files goes through +// `deps`, so the orchestration is testable without tmux or a model on the box. import { spawnSync } from "node:child_process"; import path from "node:path"; import { ENGINES, aiExecArgs, pickAiEngine, resolveEngine, resolveExecutable } from "./engines.mjs"; -import { EXIT, herdKill, herdStart, ledgerRecorder, roster, waitFor } from "./herd-cli.mjs"; +import { EXIT, carriesBypass, herdKill, herdStart, ledgerRecorder, roster, waitFor } from "./herd-cli.mjs"; import { stripAnsi } from "./herd-state.mjs"; import { endTask, screenDelta, startTask } from "./herd-tasks.mjs"; import { capture, sendKeys, sendPrompt, slugifyName } from "./herd.mjs"; +import * as openfleet from "./openfleet.mjs"; import { acid, amber, ash, bone, err, info, ok, warn } from "./ui.mjs"; export const DEFAULT_AGENTS = 4; @@ -44,6 +55,7 @@ export const MAX_AGENTS = 16; const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; const BOOT_TIMEOUT_MS = 90 * 1000; const PIECE_CHARS = 6000; +const SUMMARY_CHARS = 500; const USAGE = 'usage: moshcode swarm "" [--agents 4] [--engine claude] [--cwd .] [--verify] [--plan-only] [--keep] [--timeout 30m] [--json]'; @@ -99,8 +111,8 @@ export function planPrompt({ task, agents, cwd }) { return [ `You are planning a swarm of up to ${agents} autonomous coding agents. Each will work IN PARALLEL in its own session, in the directory ${cwd}, and cannot see the others.`, `Split the task below into at most ${agents} independent pieces that do not edit the same files. Fewer pieces is better than pieces that overlap; one piece is fine when the task does not split.`, - 'Reply with ONLY a JSON array and nothing else — no prose, no code fence: [{"title": "short name", "prompt": "the full instructions for that agent"}].', - "Each prompt must be self-contained, name the files it may touch, and tell the agent to end its work with a section headed SUMMARY: saying what it did and what it found.", + 'Reply with ONLY a JSON array and nothing else, no prose, no code fence: [{"title": "short name", "prompt": "the full instructions for that agent", "files": ["the paths, relative to the directory, that this agent alone may write"]}].', + "Each prompt must be self-contained, name the files it may touch, and tell the agent to end its work with a section headed SUMMARY: saying what it did and what it found. The files list is the same paths as data: no two pieces may share one.", NO_TOOLS, "", "TASK:", @@ -140,7 +152,12 @@ export function synthesisPrompt({ task, results }) { /* --------------------------------------------------------------- the plan */ -/** The first JSON array in a model's reply, validated into pieces. */ +/** + * The first JSON array in a model's reply, validated into pieces. A piece's + * `files` list, when the planner gives one, becomes the member's `piece.owns`: + * "do not touch bye.sh" as data moshcode can check instead of prose it never + * parses. Absent means the piece may touch anything the task names. + */ export function parsePlan(text, { agents = DEFAULT_AGENTS } = {}) { const s = String(text || ""); const from = s.indexOf("["); @@ -152,7 +169,12 @@ export function parsePlan(text, { agents = DEFAULT_AGENTS } = {}) { if (!Array.isArray(parsed)) return null; const pieces = parsed .filter((p) => p && typeof p === "object" && typeof p.prompt === "string" && p.prompt.trim()) - .map((p, i) => ({ title: String(p.title || `piece ${i + 1}`).trim().slice(0, 80), prompt: p.prompt.trim() })); + .map((p, i) => { + const piece = { title: String(p.title || `piece ${i + 1}`).trim().slice(0, 80), prompt: p.prompt.trim() }; + const files = Array.isArray(p.files) ? p.files.filter((f) => typeof f === "string" && f.trim()).map((f) => f.trim()) : []; + if (files.length) piece.files = files; + return piece; + }); return pieces.length ? pieces.slice(0, agents) : null; } @@ -167,22 +189,47 @@ export function parseVerdict(text) { } catch { return { refuted: null, reason: "the reviewer did not answer in the expected form" }; } } -/** `swarm-` — what the sessions are named after, within NAME_RE. */ -export function swarmPrefix(task, { name = null } = {}) { - const base = name || slugifyName(task).slice(0, 14).replace(/-+$/, "") || "task"; - return `swarm-${base}`.slice(0, 28); +/** + * The swarm id, minted before the plan call: `-`. Its members are + * `-`, and those are also the pane names, so the whole thing fits + * the herd's NAME_RE with room for two digits. + */ +export const swarmId = openfleet.swarmId; + +/** + * A member's closing summary: the SUMMARY: section the plan asked for when + * the agent wrote one, else the tail of what it printed. + */ +export function summaryOf(text, { max = SUMMARY_CHARS } = {}) { + const s = stripAnsi(String(text || "")).trim(); + if (!s) return ""; + const at = s.toUpperCase().lastIndexOf("SUMMARY:"); + const body = (at >= 0 ? s.slice(at + "SUMMARY:".length) : s).trim(); + return body.length > max ? body.slice(-max) : body; +} + +/** The herd's outcome for a piece, as the end state the ledger names. */ +export function endStateOf(result) { + if (result.outcome === "matched") return "done"; + if (result.outcome === "timeout") return "timeout"; + if (result.outcome === "gone") return "lost"; + return "failed"; } /* ----------------------------------------------------------- the engines */ -/** Run an engine headlessly and return what it printed. Throws on failure. */ -export function runHeadless(engine, prompt, { cwd = process.cwd(), runner = spawnSync, env = process.env } = {}) { +/** + * Run an engine headlessly and return what it printed. Throws on failure. + * `omitEnv` removes variables beyond the engine's own strip list: the + * planner runs before its swarm exists, so it must not carry OPENFLEET_SWARM. + */ +export function runHeadless(engine, prompt, { cwd = process.cwd(), runner = spawnSync, env = process.env, omitEnv = [] } = {}) { const spec = ENGINES[engine]; if (!spec) throw new Error(`no engine named ${JSON.stringify(engine)}`); const bin = resolveExecutable(spec.bin, spec.binDirs || []) || spec.bin; const args = aiExecArgs(engine, prompt); const clean = { ...env }; - for (const k of spec.stripEnv || []) delete clean[k]; + for (const k of [...(spec.stripEnv || []), ...omitEnv]) delete clean[k]; const res = runner(bin, args, { cwd, encoding: "utf8", env: clean, maxBuffer: 16 * 1024 * 1024 }); if (res.error) throw res.error; if (res.status !== 0) { @@ -235,15 +282,19 @@ export function liveDeps() { const quiet = () => {}; const look = (name) => roster().find((s) => s.name === name) || null; return { - ai: (engine, prompt, { cwd }) => runHeadless(engine, prompt, { cwd }), + ai: (engine, prompt, { cwd, omitEnv } = {}) => runHeadless(engine, prompt, { cwd, omitEnv }), // The engine's autonomous-session flags, spelled out. NOT `--agent`: for // an engine with an `agentsView` that opens its agents *overview* — the // right screen for `/agents claude`, and a screen where a typed prompt // starts a background job somewhere else instead of working here. Seen // live: two pieces "finished" in 8s with a roster for output. - start: (name, { engine, cwd, herd }) => { + // `env` is the member's OPENFLEET_* set, handed to `herd start --env` so it + // lands on the pane's env line beside MOSHCODE_HERD_NAME and survives the + // engine's strip. + start: (name, { engine, cwd, herd, env = {} }) => { const lines = []; - const argv = [engine, "--name", name, "--cwd", cwd, "--herd", herd, "--json", ...(ENGINES[engine]?.agentArgs || [])]; + const envArgs = Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]); + const argv = [engine, "--name", name, "--cwd", cwd, "--herd", herd, "--json", ...envArgs, ...(ENGINES[engine]?.agentArgs || [])]; const code = herdStart(argv, { write: (l) => lines.push(l) }); return code === EXIT.matched ? { ok: true } : { ok: false, error: lines.join(" ") || "could not start the session" }; }, @@ -261,7 +312,10 @@ export function liveDeps() { // still settling when the text lands needs longer, and an idle screen // seen before the engine has read a word is not an answer. Same ledger, // same task ids, same `moshcode herd task ` afterwards. - prompt: async (name, text, { timeoutMs }) => { + // `onSubmitted` fires the moment the herd ledger's `submit` event is + // written: the point at which a starter writes `member.start` on behalf + // of a pane whose engine cannot write its own. + prompt: async (name, text, { timeoutMs, onSubmitted = () => {} }) => { const session = look(name); if (!session?.alive) return { ok: false, task: null, outcome: "gone", state: "gone", artifact: "", error: `no live session named ${JSON.stringify(name)}` }; const at = Date.now(); @@ -273,6 +327,7 @@ export function liveDeps() { endTask(name, task, { state: "done", artifact: error }); return { ok: false, task, outcome: "failed", state: session.state, artifact: "", error }; } + try { onSubmitted({ at, task }); } catch { /* a ledger line must never fail the prompt */ } const record = ledgerRecorder(name); const began = await waitFor(name, ["working", "blocked", "done"], { timeoutMs: 30 * 1000, intervalMs: 500, onState: record }); const result = began.outcome === "gone" || (began.outcome === "matched" && began.state !== "working") @@ -283,6 +338,9 @@ export function liveDeps() { return { ok: true, task, outcome: result.outcome, state: result.state, artifact, error: null }; }, kill: async (name) => { await herdKill([name], { write: quiet }); }, + // The fleet's files. The module reads $OPENFLEET_HOME on every call, so a + // test isolates the whole thing with one env var rather than a fake. + fleet: openfleet, }; } @@ -308,19 +366,38 @@ export async function throttled(items, limit, fn) { * what `--json` prints. Never throws for an engine's failure — a piece that * failed is a piece with `state: "failed"` and the synthesis says so. */ -export async function runSwarm(options, { write = () => {}, deps = liveDeps(), engineOf = pickAiEngine } = {}) { +export async function runSwarm(options, { write = () => {}, deps = liveDeps(), engineOf = pickAiEngine, now = () => Date.now() } = {}) { const { task, agents, cwd, herd, verify, planOnly, keep, timeoutMs } = options; const engine = engineOf(options.engine); if (!engine) { return { ok: false, error: options.engine ? `no installed engine named ${JSON.stringify(options.engine)}` : "no engine installed — moshcode install claude" }; } if (!Object.hasOwn(ENGINES, engine) || !ENGINES[engine].bin) return { ok: false, error: `no engine named ${JSON.stringify(engine)}` }; + const fleetIO = deps.fleet || openfleet; + + // 0. where this swarm sits: the fleet, and the member moshcode runs inside, + // which is the parent of every member it starts. Run by hand there is no + // parent, the spawner is the sysop, and the members are roots at depth 0. + const ctx = fleetIO.context(); + const hostname = fleetIO.host(); + const { fleet, sysop } = ctx; + const parent = ctx.member || null; + const parentSwarm = ctx.record?.swarm || null; + const depth = parent ? ctx.depth + 1 : 0; + const by = parent || "sysop"; + const swarm = swarmId(task, { name: options.name, now: now() }); + const approvals = carriesBypass(engine, ENGINES[engine].agentArgs || []) ? "bypass" : "native"; + const engineString = `moshcode/${engine}`; + // The planner, the skeptic and the synthesis run in the spawner's own + // environment. They must not carry the swarm's id: it has no swarm.spawn + // yet when the planner runs, and a `claude -p` that found one would join it. + const omitEnv = ["OPENFLEET_SWARM"]; // 1. plan write(info(`plan — ${engine} is splitting the task into up to ${agents} pieces`)); let plan = null, planNote = null; try { - const reply = deps.ai(engine, planPrompt({ task, agents, cwd }), { cwd }); + const reply = deps.ai(engine, planPrompt({ task, agents, cwd }), { cwd, omitEnv }); plan = parsePlan(reply, { agents }); if (!plan) planNote = "the plan did not parse — running the task as one piece"; } catch (error) { @@ -328,67 +405,155 @@ export async function runSwarm(options, { write = () => {}, deps = liveDeps(), e } if (!plan) plan = [{ title: "the whole task", prompt: `${task}\n\nEnd your work with a section headed SUMMARY: saying what you did and what you found.` }]; if (planNote) write(warn(planNote)); - for (const [i, piece] of plan.entries()) write(` ${acid(String(i + 1).padStart(2))} ${bone(piece.title)}`); - if (planOnly) return { ok: true, engine, task, agents, plan, results: [], synthesis: null, planOnly: true }; - - // 2. fan out - const prefix = swarmPrefix(task, { name: options.name }); - write(info(`swarm — ${plan.length} session${plan.length === 1 ? "" : "s"}, ${Math.min(agents, plan.length)} at a time (${engine}, herd ${herd})`)); - const results = await throttled(plan, agents, async (piece, i) => { - const name = `${prefix}-${i + 1}`; - const started = deps.start(name, { engine, cwd, herd }); - if (!started.ok) { - write(err(`${name} — could not start: ${started.error}`)); - return { ...piece, session: name, task: null, state: "failed", outcome: "failed", artifact: "", error: String(started.error) }; - } - const boot = await deps.boot(name, { engine }); - if (boot.outcome !== "matched") { - write(err(`${name} — never became ready (${boot.outcome}, ${boot.state})`)); - if (!keep) await deps.kill(name); - return { ...piece, session: name, task: null, state: "failed", outcome: boot.outcome, artifact: "", error: "the engine never became ready" }; + for (const [i, piece] of plan.entries()) write(` ${acid(String(i + 1).padStart(2))} ${bone(piece.title)}${piece.files ? ash(` owns ${piece.files.join(",")}`) : ""}`); + if (planOnly) return { ok: true, engine, task, agents, plan, results: [], synthesis: null, planOnly: true, swarm, fleet }; + + // 2. the ceiling, checked before anything is written. The swarm narrows + // what it inherited with --agents (fan_out) and --timeout (until: a piece + // waits at most timeoutMs, and pieces beyond --agents wait their turn). + const pieces = plan.map((p, i) => ({ member: `${swarm}-${i + 1}`, title: p.title, ...(p.files ? { owns: p.files } : {}) })); + const startedAt = now(); + const narrowing = openfleet.narrowingOf({ + fan_out: agents, + until: openfleet.iso(startedAt + Math.ceil(plan.length / agents) * timeoutMs), + }, ctx.ceiling); + const ceiling = openfleet.mergeCeiling(ctx.ceiling, narrowing); + const refusal = openfleet.checkCeiling({ depth, fan_out: plan.length, hosts: [hostname] }, ceiling, { now: startedAt }) + || openfleet.checkCeiling({ approvals }, ceiling, { now: startedAt }); + if (refusal) { + const reason = `the ceiling refuses ${refusal.key}: wanted ${JSON.stringify(refusal.wanted)}, allowed ${JSON.stringify(refusal.allowed)}`; + const line = { event: "ceiling.refuse", by, key: refusal.key, wanted: refusal.wanted, allowed: refusal.allowed }; + // Approvals are a fact about each member, so each refused member gets its + // line; the swarm-wide keys refuse the spawn itself, once. + if (refusal.key === "approvals") for (const p of pieces) fleetIO.append(fleet, { ...line, member: p.member, action: "start" }, { now: startedAt }); + else fleetIO.append(fleet, { ...line, action: "spawn" }, { now: startedAt }); + write(err(`${swarm}: ${reason}`)); + const results = plan.map((piece, i) => ({ + ...piece, member: pieces[i].member, session: pieces[i].member, task: null, state: "failed", outcome: "refused", artifact: "", error: reason, + })); + return { ok: false, engine, task, agents, plan, results, synthesis: null, error: reason, kept: keep, swarm, fleet, refused: refusal }; + } + + // 3. swarm.spawn, before the first member starts + fleetIO.append(fleet, { + event: "swarm.spawn", by, swarm, ...(parentSwarm ? { parent_swarm: parentSwarm } : {}), task, ceiling: narrowing, pieces, + }, { now: startedAt }); + write(info(`swarm ${swarm}: ${plan.length} session${plan.length === 1 ? "" : "s"}, ${Math.min(agents, plan.length)} at a time (${engine}, herd ${herd}, fleet ${fleet})`)); + + const pieceOf = (i) => { const { member, ...piece } = pieces[i]; return piece; }; + const hasRealEnd = (member) => fleetIO.readLedger(fleet).some((l) => l.event === "member.end" && l.member === member && l.state !== "lost"); + const started = new Set(); + const recorded = new Set(); + let results = []; + let synthesis = null, synthesisError = null; + try { + // 4. fan out. One record per member, written before its session begins + // and never rewritten; the pane is named after the member. + results = await throttled(plan, agents, async (piece, i) => { + const member = pieces[i].member; + const name = member; + const row = (extra) => ({ ...piece, member, session: name, task: null, state: "failed", outcome: "failed", artifact: "", error: null, ...extra }); + const record = { + openfleet: openfleet.OPENFLEET_VERSION, fleet, sysop, member, ...(parent ? { parent } : {}), swarm, task, piece: pieceOf(i), depth, + engine: engineString, session: name, host: hostname, cwd, started: openfleet.iso(now()), approvals, ceiling, + }; + const written = fleetIO.writeRecord(record); + if (!written.ok) { + write(err(`${name}: could not write its record: ${written.error?.message || written.error}`)); + return row({ error: `could not write the record: ${written.error?.message || written.error}` }); + } + recorded.add(member); + const env = { + OPENFLEET_HOME: fleetIO.home(), OPENFLEET_RECORD: written.path, OPENFLEET_FLEET: fleet, OPENFLEET_MEMBER: member, OPENFLEET_SWARM: swarm, + }; + const launch = deps.start(name, { engine, cwd, herd, env }); + if (!launch.ok) { + write(err(`${name} — could not start: ${launch.error}`)); + return row({ error: String(launch.error) }); + } + started.add(name); + const boot = await deps.boot(name, { engine }); + if (boot.outcome !== "matched") { + write(err(`${name} — never became ready (${boot.outcome}, ${boot.state})`)); + return row({ outcome: boot.outcome, error: "the engine never became ready" }); + } + write(` ${ash("→")} ${bone(name)} ${ash(piece.title)}`); + // The prompt is typed into a terminal; a newline there submits early. + const text = piece.prompt.replace(/\s*\n+\s*/g, " ").trim(); + // A claude pane with hooks claims its own record; codex, deepseek and + // kimi cannot, and neither can a claude with no hooks installed. The + // starter writes member.start for whichever has none by submit time. + const onSubmitted = ({ at }) => { + if (fleetIO.hasEvent(fleet, "member.start", { member })) return; + fleetIO.append(fleet, { + at: openfleet.iso(at), event: "member.start", by, member, session: name, swarm, ...(parent ? { parent } : {}), depth, + engine: engineString, cwd, approvals, piece: pieceOf(i), + }, { now: at }); + }; + const done = await deps.prompt(name, text, { timeoutMs, onSubmitted }); + if (!done.ok) { + write(err(`${name} — ${done.error || "the prompt was not delivered"}`)); + return row({ task: done.task, outcome: done.outcome, artifact: done.artifact || "", error: done.error }); + } + const mark = done.outcome === "matched" ? acid("✓") : amber("~"); + write(` ${mark} ${bone(name)} ${ash(`${done.state} · ${done.task || ""}`)}`); + return row({ task: done.task, state: done.state || "unknown", outcome: done.outcome, artifact: done.artifact || "" }); + }); + + // 5. verify + if (verify) { + write(info(`verify — one skeptic per piece (${engine})`)); + for (const r of results) { + if (r.state === "failed") continue; + try { + r.verified = parseVerdict(deps.ai(engine, verifyPrompt({ task, piece: r, output: r.artifact.slice(-PIECE_CHARS) }), { cwd, omitEnv })); + } catch (error) { + r.verified = { refuted: null, reason: `the reviewer failed: ${error.message || error}` }; + } + const mark = r.verified.refuted === false ? acid("✓") : r.verified.refuted ? amber("✗") : ash("?"); + write(` ${mark} ${bone(r.session)} ${ash(r.verified.reason)}`); + } } - write(` ${ash("→")} ${bone(name)} ${ash(piece.title)}`); - // The prompt is typed into a terminal; a newline there submits early. - const text = piece.prompt.replace(/\s*\n+\s*/g, " ").trim(); - const done = await deps.prompt(name, text, { timeoutMs }); - if (!done.ok) { - write(err(`${name} — ${done.error || "the prompt was not delivered"}`)); - if (!keep) await deps.kill(name); - return { ...piece, session: name, task: done.task, state: "failed", outcome: done.outcome, artifact: done.artifact || "", error: done.error }; + + // 6. synthesise + write(info(`synthesis — ${engine} is folding ${results.length} piece${results.length === 1 ? "" : "s"} into one answer`)); + try { + synthesis = deps.ai(engine, synthesisPrompt({ + task, results: results.map((r) => ({ ...r, artifact: (r.artifact || r.error || "").slice(-PIECE_CHARS) })), + }), { cwd, omitEnv }); + } catch (error) { + synthesisError = `synthesis failed: ${error.message || error}`; + write(err(synthesisError)); } - const mark = done.outcome === "matched" ? acid("✓") : amber("~"); - write(` ${mark} ${bone(name)} ${ash(`${done.state} · ${done.task || ""}`)}`); - if (!keep) await deps.kill(name); - return { ...piece, session: name, task: done.task, state: done.state || "unknown", outcome: done.outcome, artifact: done.artifact || "", error: null }; - }); - // 3. verify - if (verify) { - write(info(`verify — one skeptic per piece (${engine})`)); - for (const r of results) { - if (r.state === "failed") continue; - try { - r.verified = parseVerdict(deps.ai(engine, verifyPrompt({ task, piece: r, output: r.artifact.slice(-PIECE_CHARS) }), { cwd })); - } catch (error) { - r.verified = { refuted: null, reason: `the reviewer failed: ${error.message || error}` }; + // 7. the end lines: member.end for every member whose session did not + // write its own, then one swarm.end carrying the synthesis. Not with + // --keep: the members are still running, and a swarm whose members run + // has not ended. + if (!keep) { + for (const r of results) { + if (!recorded.has(r.member) || hasRealEnd(r.member)) continue; + const state = endStateOf(r); + const summary = summaryOf(r.artifact) || (r.error ? String(r.error) : ""); + fleetIO.append(fleet, { event: "member.end", by, member: r.member, state, ...(summary ? { summary } : {}) }, { now: now() }); + } + if (!fleetIO.hasEvent(fleet, "swarm.end", { swarm })) { + const lines = fleetIO.readLedger(fleet); + const ends = pieces.map((p) => openfleet.endOf(lines, p.member)).filter(Boolean); + const verdict = verify ? results.filter((r) => r.verified).map((r) => ({ member: r.member, refuted: r.verified.refuted, reason: r.verified.reason })) : null; + fleetIO.append(fleet, { + event: "swarm.end", by, swarm, state: openfleet.swarmEndState(ends), + ...(synthesis ? { summary: synthesis } : {}), ...(verdict ? { verdict } : {}), + }, { now: now() }); } - const mark = r.verified.refuted === false ? acid("✓") : r.verified.refuted ? amber("✗") : ash("?"); - write(` ${mark} ${bone(r.session)} ${ash(r.verified.reason)}`); } + } finally { + // The kills come last, after the ledger says how everything ended, and + // in a finally so a crash between fan-out and synthesis still ends the + // panes it started rather than leaving sixteen idle engines behind. + if (!keep) for (const name of started) await deps.kill(name); } - - // 4. synthesise - write(info(`synthesis — ${engine} is folding ${results.length} piece${results.length === 1 ? "" : "s"} into one answer`)); - let synthesis = null, synthesisError = null; - try { - synthesis = deps.ai(engine, synthesisPrompt({ - task, results: results.map((r) => ({ ...r, artifact: (r.artifact || r.error || "").slice(-PIECE_CHARS) })), - }), { cwd }); - } catch (error) { - synthesisError = `synthesis failed: ${error.message || error}`; - write(err(synthesisError)); - } - return { ok: !synthesisError, engine, task, agents, plan, results, synthesis, error: synthesisError, kept: keep }; + return { ok: !synthesisError, engine, task, agents, plan, results, synthesis, error: synthesisError, kept: keep, swarm, fleet }; } /* ------------------------------------------------------------ the command */ @@ -419,6 +584,7 @@ export async function swarmCommand(argv = [], { write = console.log, deps, engin ? warn(`${result.results.length - failed} of ${result.results.length} pieces finished; ${failed} failed.`) : ok(`${result.results.length} piece${result.results.length === 1 ? "" : "s"} finished.`)); if (tasks.length) write(ash(` ledger: ${tasks.map((t) => `moshcode herd task ${t}`).join(" · ")}`)); - if (result.kept) write(info(`sessions kept: ${result.results.map((r) => r.session).join(", ")} — moshcode ps`)); + if (result.swarm && !result.refused) write(ash(` fleet: moshcode fleet log --swarm ${result.swarm}`)); + if (result.kept) write(info(`sessions kept (swarm ${result.swarm}): ${result.results.map((r) => r.session).join(", ")}. moshcode ps · moshcode fleet stop ${result.swarm}`)); return result.ok && !failed ? EXIT.matched : EXIT.usage; } diff --git a/src/tui.mjs b/src/tui.mjs index 6ee26292..0144b7cd 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -1126,6 +1126,11 @@ export async function tui() { await swarmCommand(rest, { write: (l) => console.log(` ${l}`) }); continue; } + if (cmd === "fleet") { + const { fleetCommand } = await import("./fleet-cli.mjs"); + await fleetCommand(rest, { write: (l) => console.log(` ${l}`) }); + continue; + } if (cmd === "cost" || cmd === "usage") { await herdCommand(["cost", ...rest]); continue; } if (cmd === "kill") { await herdCommand(["kill", ...rest]); continue; } if (cmd === "wait") { await herdCommand(["wait", ...rest]); continue; } diff --git a/test/fleet-cli.test.mjs b/test/fleet-cli.test.mjs new file mode 100644 index 00000000..fc927919 --- /dev/null +++ b/test/fleet-cli.test.mjs @@ -0,0 +1,369 @@ +// `moshcode fleet` (PRD 0016): the sysop's verbs over a mkdtemp OPENFLEET_HOME, +// with the herd roster and every engine stop faked. Nothing here touches +// ~/.openfleet, tmux, or a Claude Code job. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { REFUSED, fleetCommand } from "../src/fleet-cli.mjs"; +import * as fleet from "../src/openfleet.mjs"; +import { strip } from "../src/ui.mjs"; + +const NOW = Date.UTC(2026, 8, 13, 5, 42, 0); +const FLEET = "anthony@dev"; + +/** A home, a fake world, and a runner that captures what the verbs print. */ +function harness({ roster = [], env = {} } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-fleet-cli-test-")); + const lines = []; + const kills = []; + const execs = []; + const signals = []; + const opts = { + write: (l) => lines.push(strip(String(l))), + env: { OPENFLEET_HOME: dir, ...env }, + now: () => NOW, + host: "dev", + roster: () => roster, + kill: async (name) => { kills.push(name); return roster.some((r) => r.name === name) ? { ok: true } : { ok: false, error: "no such session" }; }, + exec: (bin, args) => { execs.push([bin, ...args]); return { ok: true }; }, + signal: (pid) => { signals.push(pid); return { ok: true }; }, + }; + return { + dir, lines, kills, execs, signals, opts, + run: (argv) => fleetCommand(argv, opts), + ledger: (f = FLEET) => fleet.readLedger(f, opts.env), + text: () => lines.join("\n"), + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +const at = (hhmmss) => `2026-09-13T${hhmmss}Z`; + +/** The worked example, plus a nested swarm the second piece spawned. */ +function seed(h) { + const { env } = h.opts; + const put = (line, when) => fleet.append(FLEET, { ...line, at: when }, { env, host: "dev" }); + const rec = (r) => fleet.writeRecord({ openfleet: "0.1", fleet: FLEET, sysop: FLEET, host: "dev", ...r }, { env }); + rec({ member: "460a4502", engine: "claude-code", depth: 0, approvals: "bypass", ceiling: { approvals: "bypass", depth: 1, hosts: ["dev"] } }); + put({ event: "member.start", by: "460a4502", member: "460a4502", engine: "claude-code", depth: 0, approvals: "bypass" }, at("04:55:00")); + put({ event: "swarm.spawn", by: "460a4502", swarm: "create-two-0541", task: "create two ...", ceiling: { fan_out: 4, until: at("06:11:01") }, + pieces: [{ member: "create-two-0541-1", title: "create hello.sh bash", owns: ["hello.sh"] }, { member: "create-two-0541-2", title: "create bye.sh bash", owns: ["bye.sh"] }] }, at("05:41:01")); + const ceiling = { approvals: "bypass", depth: 2, fan_out: 4, hosts: ["dev"], until: at("06:11:01") }; + rec({ member: "create-two-0541-1", parent: "460a4502", swarm: "create-two-0541", task: "create two ...", piece: { title: "create hello.sh bash", owns: ["hello.sh"] }, depth: 1, engine: "claude-code", cwd: "/x", approvals: "bypass", ceiling }); + rec({ member: "create-two-0541-2", parent: "460a4502", swarm: "create-two-0541", task: "create two ...", piece: { title: "create bye.sh bash", owns: ["bye.sh"] }, depth: 1, engine: "moshcode/claude", session: "create-two-0541-2", cwd: "/x", approvals: "bypass", ceiling }); + put({ event: "member.start", by: "create-two-0541-1", member: "create-two-0541-1", session: "172ffd83", swarm: "create-two-0541", parent: "460a4502", depth: 1, engine: "claude-code", approvals: "bypass" }, at("05:41:12")); + put({ event: "member.start", by: "460a4502", member: "create-two-0541-2", session: "create-two-0541-2", swarm: "create-two-0541", parent: "460a4502", depth: 1, engine: "moshcode/claude", approvals: "bypass" }, at("05:41:13")); + put({ event: "member.end", by: "create-two-0541-1", member: "create-two-0541-1", state: "done", summary: "Created hello.sh." }, at("05:41:36")); + // The second piece spawned a swarm of its own: a codex pane. + put({ event: "swarm.spawn", by: "create-two-0541-2", swarm: "inner-0541", parent_swarm: "create-two-0541", task: "inner", ceiling: {}, pieces: [{ member: "inner-0541-1", title: "inner piece" }] }, at("05:41:40")); + rec({ member: "inner-0541-1", parent: "create-two-0541-2", swarm: "inner-0541", task: "inner", piece: { title: "inner piece" }, depth: 2, engine: "moshcode/codex", session: "inner-0541-1", cwd: "/x", approvals: "native", ceiling: { ...ceiling, approvals: "native" } }); + put({ event: "member.start", by: "create-two-0541-2", member: "inner-0541-1", session: "inner-0541-1", swarm: "inner-0541", parent: "create-two-0541-2", depth: 2, engine: "moshcode/codex", approvals: "native" }, at("05:41:41")); +} + +const ROSTER = [ + { name: "create-two-0541-2", engine: "moshcode/claude", state: "working", alive: true, approvals: "bypass", cwd: "/x" }, + { name: "inner-0541-1", engine: "moshcode/codex", state: "working", alive: true, approvals: "native", cwd: "/x" }, + { name: "scratch", engine: "moshcode/shell", state: "idle", alive: true, approvals: "native", cwd: "/home/anthony" }, +]; + +/* --------------------------------------------------------------------- open */ + +test("open mints -, writes fleet.open by sysop with the ceiling, and makes it current", async () => { + const h = harness(); + try { + assert.equal(await h.run(["open", "--approvals", "bypass", "--depth", "2", "--until", "2h", "--hosts", "dev,netcup"]), 0); + assert.equal(h.lines[0], "fleet-20260913", "the id is printed first, on its own line"); + const [line] = h.ledger("fleet-20260913"); + assert.equal(line.event, "fleet.open"); + assert.equal(line.by, "sysop"); + assert.equal(line.sysop, fleet.implicitFleet(h.opts.env)); + assert.equal(line.host, "dev"); + assert.deepEqual(line.ceiling, { approvals: "bypass", depth: 2, hosts: ["dev", "netcup"], until: at("07:42:00") }); + assert.equal(fleet.currentFleet(h.opts.env), "fleet-20260913"); + assert.equal(fs.statSync(path.join(h.dir, "current")).mode & 0o777, 0o600); + + assert.equal(await h.run(["open", "team", "--json", "--sysop", "https://example.com/anthony.md"]), 0); + const json = JSON.parse(h.lines.at(-1)); + assert.equal(json.fleet, "team-20260913"); + assert.equal(json.sysop, "https://example.com/anthony.md"); + assert.deepEqual(json.ceiling, {}, "only the keys given; absent keys read as the spec says"); + assert.equal(fleet.currentFleet(h.opts.env), "team-20260913", "the latest open is current"); + + assert.equal(await h.run(["open", "team"]), 0); + assert.equal(h.lines.at(-2), "team-20260913-2", "a second fleet of the same name today gets a suffix"); + } finally { h.cleanup(); } +}); + +test("open validates its flags and writes nothing when one is wrong", async () => { + const h = harness(); + try { + assert.equal(await h.run(["open", "--approvals", "maybe"]), 1); + assert.match(h.text(), /native or bypass/); + assert.equal(await h.run(["open", "--budget", "lots"]), 1); + assert.equal(await h.run(["open", "--depth", "two"]), 1); + assert.equal(await h.run(["open", "--until", "someday"]), 1); + assert.equal(await h.run(["open", "--wat"]), 1); + assert.deepEqual(fleet.listFleets(h.opts.env), []); + } finally { h.cleanup(); } +}); + +test("open and cap are the sysop's: a process carrying OPENFLEET_MEMBER is refused with exit 4 and nothing is written", async () => { + const h = harness({ env: { OPENFLEET_MEMBER: "460a4502", OPENFLEET_FLEET: FLEET } }); + try { + seed(h); + const before = h.ledger().length; + assert.equal(await h.run(["open"]), REFUSED); + assert.match(h.text(), /open is the sysop's verb: this process is member 460a4502 of fleet anthony@dev/); + assert.equal(await h.run(["cap", FLEET, "--approvals", "native"]), REFUSED); + assert.equal(h.ledger().length, before); + assert.deepEqual(fleet.listFleets(h.opts.env), [FLEET]); + assert.equal(REFUSED, 4, "the same exit logicsrc fleet uses"); + } finally { h.cleanup(); } +}); + +/* ---------------------------------------------------------------------- cap */ + +test("cap on a fleet writes fleet.cap and stops the members now above the ceiling, through their own engines", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + assert.equal(await h.run(["cap", FLEET, "--approvals", "native", "--depth", "2"]), 0); + const cap = h.ledger().find((l) => l.event === "fleet.cap"); + assert.equal(cap.by, "sysop"); + assert.equal(cap.target, FLEET); + assert.deepEqual(cap.ceiling, { approvals: "native", depth: 2 }); + // 460a4502 runs bypass under a now-native ceiling: a Claude Code job, so `claude stop`. + assert.deepEqual(h.execs, [["claude", "stop", "460a4502"]]); + // create-two-0541-2 too, a moshcode pane, so the herd kills it. inner-0541-1 is native and stays. + assert.deepEqual(h.kills, ["create-two-0541-2"]); + const ends = h.ledger().filter((l) => l.event === "member.end" && l.state === "stopped"); + assert.deepEqual(ends.map((l) => l.member), ["460a4502", "create-two-0541-2"]); + assert.ok(ends.every((l) => l.by === "sysop")); + assert.match(h.text(), /460a4502 stopped/); + assert.equal(h.ledger().filter((l) => l.event === "member.end" && l.member === "create-two-0541-1").length, 1, "an ended member is left alone"); + } finally { h.cleanup(); } +}); + +test("cap on a swarm only narrows, and a widening is refused before anything is written", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + const before = h.ledger().length; + assert.equal(await h.run(["cap", "create-two-0541", "--fan-out", "8"]), 1); + assert.match(h.text(), /never widens/); + assert.equal(h.ledger().length, before); + assert.equal(await h.run(["cap", "create-two-0541"]), 1, "a swarm cap names at least one key"); + assert.equal(await h.run(["cap", "create-two-0541", "--fan-out", "2", "--json"]), 0); + const out = JSON.parse(h.lines.at(-1)); + assert.equal(out.kind, "swarm"); + assert.deepEqual(out.ceiling, { fan_out: 2 }); + assert.deepEqual(h.kills, [], "fan_out does not stop a running member"); + assert.equal(await h.run(["cap", "create-two-0541", "--approvals", "native"]), 0); + assert.deepEqual(h.kills, ["create-two-0541-2"], "the bypass member of that swarm, and only it"); + assert.deepEqual(h.execs, []); + assert.equal(await h.run(["cap", "nothing-here", "--depth", "1"]), 3); + } finally { h.cleanup(); } +}); + +/* --------------------------------------------------------------------- tree */ + +test("tree draws the fleet from the records and the ledger, joined to the herd roster", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + assert.equal(await h.run(["tree"]), 0); + const text = h.text(); + assert.match(text, /^anthony@dev \(implicit fleet, sysop anthony@dev, depth 1, hosts dev\)$/m); + assert.match(text, /^├─ 460a4502 claude-code running \[bypass\]$/m); + assert.match(text, /^│ └─ swarm create-two-0541 "create two \.\.\." 2\/4 members until 06:11$/m); + assert.match(text, /^│ ├─ create-two-0541-1 \(172ffd83\) create hello\.sh bash claude-code done \[bypass\] owns hello\.sh$/m); + assert.match(text, /^│ └─ create-two-0541-2 create bye\.sh bash moshcode\/claude working \[bypass\] owns bye\.sh$/m, "state from the roster"); + assert.match(text, /^│ └─ swarm inner-0541 "inner" 1\/4 member until 06:11$/m); + assert.match(text, /^│ └─ inner-0541-1 inner piece moshcode\/codex working$/m); + assert.match(text, /^└─ scratch \/home\/anthony moshcode\/shell idle \[roster\]$/m, "a herd session with no record is a root, marked as from the roster"); + assert.equal(h.ledger().filter((l) => l.state === "lost").length, 0); + + h.lines.length = 0; + assert.equal(await h.run(["tree", "--json"]), 0); + const model = JSON.parse(h.text()); + assert.equal(model.fleets[0].fleet, FLEET); + assert.equal(model.fleets[0].nodes[0].member, "460a4502"); + assert.equal(await h.run(["tree", "no-such-fleet"]), 3); + } finally { h.cleanup(); } +}); + +test("tree writes member.end lost for a claimed moshcode member the roster no longer lists, by the caller", async () => { + const h = harness({ roster: [ROSTER[2]] }); + try { + seed(h); + assert.equal(await h.run(["tree"]), 0); + const lost = h.ledger().filter((l) => l.event === "member.end" && l.state === "lost"); + assert.deepEqual(lost.map((l) => l.member).sort(), ["create-two-0541-2", "inner-0541-1"]); + assert.ok(lost.every((l) => l.by === "sysop")); + assert.match(h.text(), /create-two-0541-2 create bye\.sh bash moshcode\/claude lost/); + h.lines.length = 0; + assert.equal(await h.run(["tree"]), 0); + assert.equal(h.ledger().filter((l) => l.state === "lost").length, 2, "written once, not on every look"); + + const agent = harness({ roster: [], env: { OPENFLEET_MEMBER: "460a4502" } }); + try { + seed(agent); + await agent.run(["tree"]); + assert.ok(agent.ledger().filter((l) => l.state === "lost").every((l) => l.by === "460a4502"), "an agent's tool never writes sysop"); + } finally { agent.cleanup(); } + } finally { h.cleanup(); } +}); + +test("an empty home says so, and a bare `fleet` is the tree", async () => { + const h = harness(); + try { + assert.equal(await h.run([]), 0); + assert.match(h.text(), /no fleet yet/); + assert.equal(await h.run(["--json"]), 0); + assert.deepEqual(JSON.parse(h.lines.at(-1)).fleets, []); + assert.equal(await h.run(["dance"]), 1); + assert.match(h.text(), /unknown fleet verb "dance"/); + } finally { h.cleanup(); } +}); + +/* --------------------------------------------------------------------- stop */ + +test("stop on a swarm ends nested swarms first, then its members through their engines, then writes one swarm.end", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + assert.equal(await h.run(["stop", "create-two-0541"]), 0); + assert.deepEqual(h.kills, ["inner-0541-1", "create-two-0541-2"], "the nested swarm's member goes first"); + assert.deepEqual(h.execs, [], "create-two-0541-1 had already ended: nothing is stopped twice"); + const tail = h.ledger().slice(-4).map((l) => `${l.event}:${l.member || l.swarm}:${l.state}`); + assert.deepEqual(tail, [ + "member.end:inner-0541-1:stopped", + "swarm.end:inner-0541:stopped", + "member.end:create-two-0541-2:stopped", + "swarm.end:create-two-0541:stopped", + ]); + assert.ok(h.ledger().slice(-4).every((l) => l.by === "sysop")); + assert.match(h.text(), /swarm create-two-0541 ended stopped/); + + h.lines.length = 0; + assert.equal(await h.run(["stop", "create-two-0541", "--json"]), 0); + const again = JSON.parse(h.text()); + assert.ok(again.members.every((m) => m.outcome === "already-ended")); + assert.ok(again.swarms.every((s) => s.state === "already-ended"), "one swarm.end per swarm, never two"); + assert.equal(h.ledger().filter((l) => l.event === "swarm.end").length, 2); + } finally { h.cleanup(); } +}); + +test("stop on a member ends that member and nothing else; an ended member writes nothing", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + assert.equal(await h.run(["stop", "inner-0541-1"]), 0); + assert.deepEqual(h.kills, ["inner-0541-1"]); + assert.equal(h.ledger().at(-1).event, "member.end"); + assert.equal(h.ledger().at(-1).member, "inner-0541-1"); + assert.equal(h.ledger().filter((l) => l.event === "swarm.end").length, 0); + const before = h.ledger().length; + assert.equal(await h.run(["stop", "create-two-0541-1"]), 0); + assert.equal(h.ledger().length, before); + assert.match(h.text(), /had already ended \(done\)/); + assert.equal(await h.run(["stop", "nobody"]), 3); + assert.equal(await h.run(["stop"]), 1); + } finally { h.cleanup(); } +}); + +test("stop marks a member lost when its engine no longer has it, and reports one it cannot reach", async () => { + const h = harness({ roster: [] }); + try { + seed(h); + assert.equal(await h.run(["stop", "inner-0541-1"]), 0); + assert.equal(h.ledger().at(-1).state, "lost"); + fleet.writeRecord({ openfleet: "0.1", fleet: FLEET, sysop: FLEET, member: "odd-1", engine: "gemini-cli", session: "x" }, { env: h.opts.env }); + fleet.append(FLEET, { event: "member.start", by: "odd-1", member: "odd-1", engine: "gemini-cli" }, { env: h.opts.env, host: "dev" }); + assert.equal(await h.run(["stop", "odd-1"]), 3); + assert.match(h.text(), /no engine to stop it through/); + assert.ok(!h.ledger().some((l) => l.event === "member.end" && l.member === "odd-1"), "a member that was not stopped gets no end line"); + fleet.writeRecord({ openfleet: "0.1", fleet: FLEET, sysop: FLEET, member: "p-1", engine: "claude-p", session: "4242" }, { env: h.opts.env }); + fleet.append(FLEET, { event: "member.start", by: "p-1", member: "p-1", engine: "claude-p", session: "4242" }, { env: h.opts.env, host: "dev" }); + assert.equal(await h.run(["stop", "p-1"]), 0); + assert.deepEqual(h.signals, [4242], "a claude -p is its pid"); + } finally { h.cleanup(); } +}); + +test("an agent stops only a swarm it spawned, or a member under one; --fleet, an ancestor and a sibling's swarm refuse", async () => { + const spawner = harness({ roster: ROSTER, env: { OPENFLEET_MEMBER: "460a4502" } }); + try { + seed(spawner); + assert.equal(await spawner.run(["stop", "--fleet", FLEET]), REFUSED); + assert.match(spawner.text(), /stop --fleet is the sysop's/); + assert.equal(await spawner.run(["stop", "inner-0541-1"]), 0, "a member under a swarm it spawned, however deep"); + assert.equal(spawner.ledger().at(-1).by, "460a4502", "an agent's action carries its own id"); + assert.equal(await spawner.run(["stop", "create-two-0541"]), 0); + } finally { spawner.cleanup(); } + + const sibling = harness({ roster: ROSTER, env: { OPENFLEET_MEMBER: "create-two-0541-1" } }); + try { + seed(sibling); + assert.equal(await sibling.run(["stop", "create-two-0541"]), REFUSED, "its own swarm is its parent's, not its own"); + assert.equal(await sibling.run(["stop", "inner-0541"]), REFUSED, "a sibling's swarm"); + assert.equal(await sibling.run(["stop", "460a4502"]), REFUSED, "an ancestor"); + assert.match(sibling.text(), /outside what member create-two-0541-1 spawned/); + assert.deepEqual(sibling.kills, []); + } finally { sibling.cleanup(); } + + const inner = harness({ roster: ROSTER, env: { OPENFLEET_MEMBER: "create-two-0541-2" } }); + try { + seed(inner); + assert.equal(await inner.run(["stop", "inner-0541"]), 0, "the swarm it spawned"); + assert.deepEqual(inner.kills, ["inner-0541-1"]); + } finally { inner.cleanup(); } +}); + +test("stop --fleet ends every swarm and root member in the fleet", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + assert.equal(await h.run(["stop", "--fleet", FLEET]), 0); + // scratch has no record, but a herd session with none is a root member of + // the implicit fleet (rule 12), and --fleet means everything in it. + assert.deepEqual(h.kills, ["inner-0541-1", "create-two-0541-2", "scratch"]); + assert.deepEqual(h.execs, [["claude", "stop", "460a4502"]]); + assert.equal(h.ledger().filter((l) => l.event === "swarm.end").length, 2); + assert.equal(await h.run(["stop", "--fleet", "nope"]), 3); + } finally { h.cleanup(); } +}); + +/* ---------------------------------------------------------------------- log */ + +test("log reads the ledger in order and filters by member, swarm and since; --json is one object per line", async () => { + const h = harness(); + try { + seed(h); + assert.equal(await h.run(["log"]), 0); + const all = h.lines; + assert.equal(all.length, h.ledger().length); + assert.match(all[0], /^2026-09-13T04:55:00Z member\.start\s+by 460a4502 460a4502 · claude-code · bypass$/); + assert.match(all[1], /swarm\.spawn\s+by 460a4502 create-two-0541 "create two \.\.\." · 2 pieces · fan_out 4, until 2026-09-13T06:11:01Z/); + assert.match(all[4], /member\.end\s+by create-two-0541-1 create-two-0541-1 · done · Created hello\.sh\./); + + h.lines.length = 0; + assert.equal(await h.run(["log", "--member", "create-two-0541-1"]), 0); + assert.deepEqual(h.lines.map((l) => l.split(/\s+/)[1]), ["member.start", "member.end"]); + + h.lines.length = 0; + assert.equal(await h.run(["log", "--swarm", "create-two-0541", "--json"]), 0); + const events = h.lines.map((l) => JSON.parse(l)).map((l) => l.event); + assert.deepEqual(events, ["swarm.spawn", "member.start", "member.start", "member.end"], "a member's end line names no swarm, so membership comes from the records"); + + h.lines.length = 0; + assert.equal(await h.run(["log", "--since", at("05:41:40"), "--json"]), 0); + assert.deepEqual(h.lines.map((l) => JSON.parse(l).event), ["swarm.spawn", "member.start"]); + h.lines.length = 0; + assert.equal(await h.run(["log", "--since", "10s"]), 0); + assert.match(h.text(), /nothing in the ledger matches/); + assert.equal(await h.run(["log", "--since", "yesterday-ish"]), 1); + } finally { h.cleanup(); } +}); diff --git a/test/herd-cli.test.mjs b/test/herd-cli.test.mjs index b94ea182..9dbe4545 100644 --- a/test/herd-cli.test.mjs +++ b/test/herd-cli.test.mjs @@ -8,8 +8,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { - EXIT, herdRun, humanAge, paintState, renderRoster, shouldNotify, splitDetachArgs, waitFor, + EXIT, carriesBypass, groupByFleet, herdRun, humanAge, paintState, parseStartArgs, renderRoster, shouldNotify, splitDetachArgs, waitFor, } from "../src/herd-cli.mjs"; +import { ENGINES } from "../src/engines.mjs"; +import { strip } from "../src/ui.mjs"; const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); @@ -211,3 +213,58 @@ test("ages read as durations, not milliseconds", () => { assert.equal(humanAge(50 * 3600000), "2d"); assert.equal(humanAge(null), "", "an unknown age is blank, not NaN"); }); + +/* ------------------------------------------------------------- the fleet */ + +test("carriesBypass is true for --agent and for the engine's own bypass flags passed as plain args", () => { + // The untruthful `agent: false` the spec called out: a swarm pane runs + // `claude --dangerously-skip-permissions` through plain args, and the + // manifest said it did not (PRD 0016). + assert.equal(carriesBypass(ENGINES.claude, ["--dangerously-skip-permissions"]), true); + assert.equal(carriesBypass("claude", ["--dangerously-skip-permissions", "--model", "opus"]), true); + assert.equal(carriesBypass(ENGINES.claude, ["--model", "opus"]), false); + assert.equal(carriesBypass(ENGINES.claude, []), false); + assert.equal(carriesBypass(ENGINES.claude, [], { agent: true }), true); + assert.equal(carriesBypass(ENGINES.codex, ["--dangerously-bypass-approvals-and-sandbox"]), true); + assert.equal(carriesBypass(ENGINES.kimi, ["--yolo"]), true); + assert.equal(carriesBypass({ bin: "sh" }, ["--yolo"]), false, "an engine with no bypass flags never bypasses"); + assert.equal(carriesBypass("no-such-engine", ["--yolo"]), false); +}); + +test("herd start --env is repeatable, takes KEY=VALUE, and refuses anything else", () => { + const parsed = parseStartArgs(["claude", "--env", "OPENFLEET_MEMBER=create-two-0541-1", "--env=OPENFLEET_SWARM=create-two-0541", "--name", "create-two-0541-1", "--model", "opus"]); + assert.deepEqual(parsed.flags.env, { OPENFLEET_MEMBER: "create-two-0541-1", OPENFLEET_SWARM: "create-two-0541" }); + assert.equal(parsed.flags.name, "create-two-0541-1"); + assert.deepEqual(parsed.rest, ["claude", "--model", "opus"], "the herd's flags never reach the engine"); + assert.deepEqual(parsed.errors, []); + assert.deepEqual(parseStartArgs(["claude", "--env", "PATH=/x=y"]).flags.env, { PATH: "/x=y" }, "the first = splits; the value may hold more"); + assert.match(parseStartArgs(["claude", "--env", "novalue"]).errors[0], /KEY=VALUE/); + assert.match(parseStartArgs(["claude", "--env", "1bad=x"]).errors[0], /KEY=VALUE/); + assert.deepEqual(parseStartArgs(["claude"]).flags.env, {}); +}); + +test("the roster groups by fleet and swarm when a member carries one, and marks bypass", () => { + const rows = [ + session(), + session({ name: "create-two-0541-1", fleet: "anthony@dev", swarm: "create-two-0541", approvals: "bypass" }), + session({ name: "create-two-0541-2", fleet: "anthony@dev", swarm: "create-two-0541", approvals: "bypass" }), + session({ name: "root-1", fleet: "anthony@dev", approvals: "native" }), + ]; + const groups = groupByFleet(rows); + assert.deepEqual(groups.map((g) => g.fleet), [null, "anthony@dev"], "rows with no fleet come first"); + assert.deepEqual(groups[1].swarms.map((s) => s.swarm), [null, "create-two-0541"], "members outside any swarm before the swarms"); + const lines = strip(renderRoster(rows)).split("\n"); + assert.match(lines[0], /^ api\s+claude\s+working/); + assert.equal(lines[1], " anthony@dev fleet"); + assert.match(lines[2], /^ root-1\s+claude/); + assert.ok(!/bypass/.test(lines[2])); + assert.equal(lines[3], " swarm create-two-0541"); + assert.match(lines[4], /^ create-two-0541-1\s+claude\s+working.*bypass$/); + assert.match(lines[5], /^ create-two-0541-2.*bypass$/); + assert.equal(lines.length, 6); + // Without a fleet anywhere the roster is the flat list it always was, with the mark still shown. + const flat = strip(renderRoster([session(), session({ name: "auto", approvals: "bypass" })])).split("\n"); + assert.equal(flat.length, 2); + assert.match(flat[1], /auto.*bypass$/); + assert.ok(!/bypass/.test(flat[0])); +}); diff --git a/test/herd.test.mjs b/test/herd.test.mjs index b9b531b0..80325f54 100644 --- a/test/herd.test.mjs +++ b/test/herd.test.mjs @@ -7,8 +7,8 @@ import os from "node:os"; import path from "node:path"; import { - defaultName, detectSubstrate, forgetSession, NAME_RE, readManifest, rememberSession, - resetSubstrate, sessionCommand, slugifyName, substrateNote, tmuxStartPlan, validName, + defaultName, detectSubstrate, forgetSession, listSessions, NAME_RE, readManifest, rememberSession, + resetSubstrate, sessionCommand, sessionEnv, slugifyName, startSession, substrateNote, tmuxStartPlan, validName, writeManifest, } from "../src/herd.mjs"; @@ -184,3 +184,49 @@ test("the substrate note names a fix rather than only a problem", () => { assert.match(substrateNote("pty"), /tmux/, "say what would make it better"); assert.match(substrateNote(null), /install tmux|apt|brew/, "and how to get it"); }); + +/* --------------------------------------------------------------- the fleet */ + +test("OPENFLEET_* ride the session's env line beside the herd's own, and survive the strip", () => { + // tmux does not inherit the caller's environment, and the engine's strip is + // an explicit `-u` list, so the fleet's variables have to be on the same + // prefix as MOSHCODE_HERD_NAME to reach the pane at all (PRD 0016, rule 9). + withHerdDir(() => { + const calls = []; + const runner = (bin, args) => { + calls.push(args); + if (args[0] === "-V") return { status: 0, stdout: "tmux 3.5\n", stderr: "" }; + return { status: 0, stdout: "", stderr: "" }; + }; + const result = startSession({ + name: "create-two-0541-2", engine: "claude", bin: "/bin/true", cwd: "/tmp", substrate: "tmux", runner, + stripEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_SESSION_ID"], + extraEnv: { OPENFLEET_RECORD: "/x/fleets/anthony@dev/members/create-two-0541-2.json", OPENFLEET_MEMBER: "create-two-0541-2" }, + }); + assert.equal(result.ok, true); + const start = calls.find((a) => a.includes("new-session")); + const command = start[start.indexOf("-y") + 2]; + assert.match(command, /'-u' 'CLAUDE_CODE_SESSION_ID'.*'MOSHCODE_HERD_NAME=create-two-0541-2'.*'OPENFLEET_RECORD=\/x\/fleets\/anthony@dev\/members\/create-two-0541-2\.json'/); + assert.match(command, /'OPENFLEET_MEMBER=create-two-0541-2'/); + assert.ok(!/'-u' 'OPENFLEET/.test(command), "the fleet's variables are never on the strip list"); + const plain = sessionCommand({ bin: "claude", stripEnv: ["CLAUDE_CODE_SESSION_ID"], setEnv: { ...sessionEnv("api"), OPENFLEET_SWARM: "create-two-0541" } }); + assert.match(plain, /'-u' 'CLAUDE_CODE_SESSION_ID' 'MOSHCODE_HERD_NAME=api' 'MOSHCODE_HERD_DIR=[^']+' 'OPENFLEET_SWARM=create-two-0541'/); + }); +}); + +test("a roster row says where a member sits in its fleet, and whether its approvals are bypassed", () => { + withHerdDir(() => { + rememberSession("create-two-0541-2", { engine: "claude", cwd: "/x", created: 1, fleet: "anthony@dev", swarm: "create-two-0541", member: "create-two-0541-2", approvals: "bypass" }); + rememberSession("old-agent", { engine: "claude", cwd: "/x", created: 1, agent: true }); + rememberSession("plain", { engine: "codex", cwd: "/x", created: 1 }); + const rows = listSessions({ substrate: "tmux", runner: () => ({ status: 0, stdout: "", stderr: "" }) }); + const by = Object.fromEntries(rows.map((r) => [r.name, r])); + assert.equal(by["create-two-0541-2"].fleet, "anthony@dev"); + assert.equal(by["create-two-0541-2"].swarm, "create-two-0541"); + assert.equal(by["create-two-0541-2"].member, "create-two-0541-2"); + assert.equal(by["create-two-0541-2"].approvals, "bypass"); + assert.equal(by["old-agent"].approvals, "bypass", "an entry from before the key still reads its agent mark"); + assert.equal(by["old-agent"].fleet, null); + assert.equal(by.plain.approvals, "native"); + }); +}); diff --git a/test/openfleet.test.mjs b/test/openfleet.test.mjs new file mode 100644 index 00000000..982a9462 --- /dev/null +++ b/test/openfleet.test.mjs @@ -0,0 +1,446 @@ +// OpenFleet IO (PRD 0016): the record that is written once, the ledger that is +// only appended to and merged across hosts, the ceiling that only narrows, and +// the fold that turns both into the tree. Every test runs against a mkdtemp +// OPENFLEET_HOME; nothing here touches ~/.openfleet. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + CEILING_KEYS, append, checkCeiling, claimedBy, context, currentFleet, effectiveCeiling, endOf, findEvents, fleetCeiling, fold, + hasEvent, implicitFleet, isNarrower, iso, ledgerPaths, listFleets, listRecords, mergeCeiling, narrowingOf, parseBudget, readLedger, + readMember, readRecord, recordPath, renderTree, sumSpend, swarmChain, swarmEndState, swarmId, writeCurrent, writeRecord, +} from "../src/openfleet.mjs"; +import { NAME_RE } from "../src/herd.mjs"; + +const OPENFLEET_VARS = ["OPENFLEET_HOME", "OPENFLEET_RECORD", "OPENFLEET_FLEET", "OPENFLEET_MEMBER", "OPENFLEET_SWARM"]; + +/** Each test gets its own home; the module reads the env var on every call. */ +function withHome(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-openfleet-test-")); + const previous = Object.fromEntries(OPENFLEET_VARS.map((k) => [k, process.env[k]])); + for (const k of OPENFLEET_VARS) delete process.env[k]; + process.env.OPENFLEET_HOME = dir; + try { return fn(dir); } + finally { + for (const k of OPENFLEET_VARS) { + if (previous[k] === undefined) delete process.env[k]; + else process.env[k] = previous[k]; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const mode = (file) => fs.statSync(file).mode & 0o777; + +/* ------------------------------------------------------------- the record */ + +test("a record is written once, at 0600, and never overwritten", () => { + withHome((dir) => { + const record = { openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "create-two-0541-1", extra: "kept" }; + const first = writeRecord(record); + assert.equal(first.ok, true); + assert.equal(first.path, path.join(dir, "fleets", "anthony@dev", "members", "create-two-0541-1.json")); + assert.equal(first.path, recordPath("anthony@dev", "create-two-0541-1")); + assert.equal(mode(first.path), 0o600); + assert.equal(mode(path.dirname(first.path)), 0o700, "the directory is private too"); + assert.deepEqual(readRecord(first.path), record, "unknown keys are kept"); + assert.deepEqual(readMember("anthony@dev", "create-two-0541-1"), record); + const second = writeRecord({ ...record, member: "create-two-0541-1", sysop: "impostor" }); + assert.equal(second.ok, false, "the record never changes after it is written"); + assert.equal(readRecord(first.path).sysop, "anthony@dev"); + assert.deepEqual(listFleets(), ["anthony@dev"]); + assert.deepEqual(listRecords("anthony@dev").map((r) => r.member), ["create-two-0541-1"]); + }); +}); + +test("a missing or torn record reads as null rather than a throw", () => { + withHome((dir) => { + assert.equal(readRecord(path.join(dir, "nope.json")), null); + fs.mkdirSync(path.join(dir, "x"), { recursive: true }); + fs.writeFileSync(path.join(dir, "x", "torn.json"), "{ not json"); + assert.equal(readRecord(path.join(dir, "x", "torn.json")), null); + assert.deepEqual(listRecords("nobody"), []); + }); +}); + +test("current names the fleet new roots join, and absent means the implicit fleet", () => { + withHome(() => { + assert.equal(currentFleet(), null); + assert.equal(writeCurrent("fleet-20260913"), true); + assert.equal(currentFleet(), "fleet-20260913"); + assert.match(implicitFleet(), /^[^@]+@[^@]+$/); + }); +}); + +/* ------------------------------------------------------------- the ledger */ + +test("append adds at, fleet and host, keeps by and the event's keys, at 0600", () => { + withHome((dir) => { + const line = append("anthony@dev", { event: "swarm.spawn", by: "460a4502", swarm: "create-two-0541", task: "create two" }, { now: Date.UTC(2026, 8, 13, 5, 41, 1), host: "dev" }); + assert.deepEqual(Object.keys(line), ["at", "event", "fleet", "host", "by", "swarm", "task"], "the five common keys come first, in the spec's order"); + assert.equal(line.at, "2026-09-13T05:41:01Z"); + assert.equal(line.fleet, "anthony@dev"); + assert.equal(line.host, "dev"); + const file = path.join(dir, "fleets", "anthony@dev", "ledger.jsonl"); + assert.equal(mode(file), 0o600); + assert.equal(fs.readFileSync(file, "utf8"), `${JSON.stringify(line)}\n`); + // A caller that knows the real time of the event keeps it: member.start at the submit moment. + const kept = append("anthony@dev", { at: "2026-09-13T05:41:12Z", event: "member.start", by: "create-two-0541-1", member: "create-two-0541-1" }, { host: "dev" }); + assert.equal(kept.at, "2026-09-13T05:41:12Z"); + assert.equal(readLedger("anthony@dev").length, 2); + }); +}); + +test("append never throws: an unwritable home is a null line, not a failed swarm", () => { + withHome((dir) => { + const blocked = path.join(dir, "blocked"); + fs.writeFileSync(blocked, "a file where the home should be"); + process.env.OPENFLEET_HOME = blocked; + assert.equal(append("f", { event: "x", by: "sysop" }), null); + }); +}); + +test("readLedger merges every ledger*.jsonl under a fleet by at, and skips torn lines", () => { + withHome((dir) => { + const fleetDir = path.join(dir, "fleets", "team-20260913"); + fs.mkdirSync(fleetDir, { recursive: true }); + fs.writeFileSync(path.join(fleetDir, "ledger.jsonl"), [ + JSON.stringify({ at: "2026-09-13T05:41:01Z", event: "swarm.spawn", fleet: "team-20260913", host: "dev", by: "a", swarm: "s" }), + JSON.stringify({ at: "2026-09-13T05:43:00Z", event: "member.end", fleet: "team-20260913", host: "dev", by: "a", member: "s-1", state: "done" }), + "{ torn", + ].join("\n")); + fs.writeFileSync(path.join(fleetDir, "ledger.netcup.jsonl"), [ + JSON.stringify({ at: "2026-09-13T05:42:00Z", event: "member.start", fleet: "team-20260913", host: "netcup", by: "s-2", member: "s-2" }), + "", + ].join("\n")); + assert.equal(ledgerPaths("team-20260913").length, 2); + const lines = readLedger("team-20260913"); + assert.deepEqual(lines.map((l) => l.event), ["swarm.spawn", "member.start", "member.end"]); + assert.deepEqual(lines.map((l) => l.host), ["dev", "netcup", "dev"]); + assert.deepEqual(readLedger("nobody"), []); + }); +}); + +test("a record with no member.start is unclaimed; the first start claims it", () => { + withHome(() => { + writeRecord({ openfleet: "0.1", fleet: "f", sysop: "s", member: "m-1" }); + assert.equal(claimedBy(readLedger("f"), "m-1"), null); + assert.equal(hasEvent("f", "member.start", { member: "m-1" }), false); + append("f", { event: "member.start", by: "m-1", member: "m-1", session: "172ffd83" }); + append("f", { event: "member.start", by: "m-1", member: "m-1", session: "second" }); + assert.equal(claimedBy(readLedger("f"), "m-1").session, "172ffd83", "the first claim counts"); + assert.equal(hasEvent("f", "member.start", { member: "m-1" }), true); + assert.equal(hasEvent("f", "member.start", { member: "m-2" }), false); + }); +}); + +test("one end line counts: the first written, except lost, which a real end supersedes", () => { + const lines = [ + { event: "member.end", member: "a", state: "lost" }, + { event: "member.end", member: "a", state: "done" }, + { event: "member.end", member: "b", state: "failed" }, + { event: "member.end", member: "b", state: "done" }, + { event: "member.end", member: "c", state: "lost" }, + ]; + assert.equal(endOf(lines, "a").state, "done", "lost gives way to the engine's own end"); + assert.equal(endOf(lines, "b").state, "failed", "otherwise the first line counts"); + assert.equal(endOf(lines, "c").state, "lost", "lost stands until something real arrives"); + assert.equal(endOf(lines, "d"), null); + assert.equal(findEvents(lines, "member.end", { member: "b" }).length, 2); +}); + +test("a swarm ends done only when every member did, else the first failure state seen", () => { + assert.equal(swarmEndState(["done", "done"]), "done"); + assert.equal(swarmEndState([{ state: "done" }, { state: "timeout" }, { state: "failed" }]), "timeout"); + assert.equal(swarmEndState(["stopped", "done"]), "stopped"); + assert.equal(swarmEndState(["done", "lost"]), "failed", "a lost member is not a finished swarm"); + assert.equal(swarmEndState([]), "failed", "nothing ended means nothing was done"); +}); + +/* ------------------------------------------------------------ the ceiling */ + +test("a ceiling merges key by key, and unknown keys ride along", () => { + const fleet = { approvals: "bypass", depth: 2, hosts: ["dev"], budget: "20 USD" }; + assert.deepEqual(mergeCeiling(fleet, { fan_out: 4, until: "2026-09-13T06:11:01Z" }), { ...fleet, fan_out: 4, until: "2026-09-13T06:11:01Z" }); + assert.deepEqual(mergeCeiling(fleet, {}), fleet); + assert.deepEqual(mergeCeiling(fleet, { depth: null, approvals: undefined }), fleet, "null and undefined are not narrowings"); + assert.deepEqual(CEILING_KEYS, ["approvals", "budget", "depth", "fan_out", "hosts", "until"]); +}); + +test("narrower means: native under bypass, smaller numbers in the same unit, a subset of hosts, an earlier until", () => { + const base = { approvals: "bypass", budget: "20 USD", depth: 2, fan_out: 4, hosts: ["dev", "netcup"], until: "2026-09-13T06:11:01Z" }; + assert.equal(isNarrower({ approvals: "native" }, base), true); + assert.equal(isNarrower({ approvals: "bypass" }, base), true, "equal is within"); + assert.equal(isNarrower({ approvals: "bypass" }, { ...base, approvals: "native" }), false); + assert.equal(isNarrower({ approvals: "bypass" }, {}), false, "absent means native"); + assert.equal(isNarrower({ budget: "10 USD" }, base), true); + assert.equal(isNarrower({ budget: "30 USD" }, base), false); + assert.equal(isNarrower({ budget: "10 EUR" }, base), false, "a different unit is not comparable"); + assert.equal(isNarrower({ budget: "1000 tokens" }, {}), true, "absent means uncapped"); + assert.equal(isNarrower({ depth: 1 }, base), true); + assert.equal(isNarrower({ depth: 3 }, base), false); + assert.equal(isNarrower({ depth: 2 }, {}), false, "absent depth means 1"); + assert.equal(isNarrower({ fan_out: 2 }, base), true); + assert.equal(isNarrower({ fan_out: 8 }, base), false); + assert.equal(isNarrower({ fan_out: 8 }, {}), true); + assert.equal(isNarrower({ hosts: ["dev"] }, base), true); + assert.equal(isNarrower({ hosts: ["dev", "mars"] }, base), false); + assert.equal(isNarrower({ hosts: ["mars"] }, {}), true); + assert.equal(isNarrower({ until: "2026-09-13T06:00:00Z" }, base), true); + assert.equal(isNarrower({ until: "2026-09-13T07:00:00Z" }, base), false); + assert.equal(isNarrower({ until: "not a time" }, base), false); +}); + +test("narrowingOf keeps only the keys that actually narrow, so a spawner never writes a widening", () => { + const base = { approvals: "bypass", depth: 1, hosts: ["dev"], fan_out: 2 }; + assert.deepEqual(narrowingOf({ fan_out: 4, until: "2026-09-13T06:11:01Z" }, base), { until: "2026-09-13T06:11:01Z" }, "fan_out 4 under 2 is dropped"); + assert.deepEqual(narrowingOf({ fan_out: 4, until: "2026-09-13T06:11:01Z" }, { depth: 1, hosts: ["dev"] }), { fan_out: 4, until: "2026-09-13T06:11:01Z" }); + assert.deepEqual(narrowingOf({ hosts: ["dev"], depth: 1 }, base), {}, "what is inherited is not written twice"); +}); + +test("checkCeiling names the first key that would be exceeded, with both values", () => { + const allowed = { approvals: "native", depth: 1, fan_out: 4, hosts: ["dev"], until: "2026-09-13T06:11:01Z", budget: "20 USD" }; + const now = Date.UTC(2026, 8, 13, 5, 41, 0); + assert.deepEqual(checkCeiling({ approvals: "bypass" }, allowed, { now }), { key: "approvals", wanted: "bypass", allowed: "native" }); + assert.equal(checkCeiling({ approvals: "bypass" }, { depth: 1, hosts: ["dev"] }, { now }), null, "no approvals key in the implicit fleet: the root supplies its own"); + assert.equal(checkCeiling({ approvals: "native" }, allowed, { now }), null); + assert.deepEqual(checkCeiling({ depth: 2 }, allowed, { now }), { key: "depth", wanted: 2, allowed: 1 }); + assert.deepEqual(checkCeiling({ depth: 2 }, {}, { now }), { key: "depth", wanted: 2, allowed: 1 }, "absent depth means 1"); + assert.equal(checkCeiling({ depth: 1 }, allowed, { now }), null); + assert.deepEqual(checkCeiling({ fan_out: 5 }, allowed, { now }), { key: "fan_out", wanted: 5, allowed: 4 }); + assert.equal(checkCeiling({ fan_out: 50 }, { depth: 1 }, { now }), null, "absent fan_out is the engine's default"); + assert.deepEqual(checkCeiling({ hosts: ["netcup"] }, allowed, { now }), { key: "hosts", wanted: ["netcup"], allowed: ["dev"] }); + assert.deepEqual(checkCeiling({ host: "netcup" }, allowed, { now }), { key: "hosts", wanted: ["netcup"], allowed: ["dev"] }); + assert.deepEqual(checkCeiling({}, allowed, { now: Date.UTC(2026, 8, 13, 6, 12, 0) }), { key: "until", wanted: "2026-09-13T06:12:00Z", allowed: "2026-09-13T06:11:01Z" }); + assert.deepEqual(checkCeiling({ until: "2026-09-13T07:00:00Z" }, allowed, { now }), { key: "until", wanted: "2026-09-13T07:00:00Z", allowed: "2026-09-13T06:11:01Z" }); + assert.deepEqual(checkCeiling({ budget: "25 USD" }, allowed, { now }), { key: "budget", wanted: "25 USD", allowed: "20 USD" }); + assert.equal(checkCeiling({ depth: 1, fan_out: 2, hosts: ["dev"], budget: "5 USD" }, allowed, { now }), null); + assert.deepEqual(parseBudget("20 USD"), { amount: 20, unit: "USD" }); + assert.deepEqual(parseBudget("5000 tokens"), { amount: 5000, unit: "tokens" }); + assert.equal(parseBudget("lots"), null); +}); + +test("a fleet's ceiling is the latest cap on it, else fleet.open, else the implicit fleet's", () => { + const implicit = fleetCeiling([], "anthony@dev", { host: "dev" }); + assert.deepEqual(implicit, { ceiling: { depth: 1, hosts: ["dev"] }, opened: false, line: null }); + const open = { at: "2026-09-13T05:00:00Z", event: "fleet.open", fleet: "f", host: "dev", by: "sysop", sysop: "anthony@dev", ceiling: { approvals: "bypass", depth: 2 } }; + const opened = fleetCeiling([open], "f", { host: "netcup" }); + assert.equal(opened.opened, true); + assert.deepEqual(opened.ceiling, { approvals: "bypass", depth: 2, hosts: ["dev"] }, "absent hosts means the host the line was written on"); + const bare = fleetCeiling([{ ...open, ceiling: {} }], "f", { host: "dev" }); + assert.deepEqual(bare.ceiling, { approvals: "native", depth: 1, hosts: ["dev"] }, "an opened fleet with no approvals means native"); + const cap = { at: "2026-09-13T05:30:00Z", event: "fleet.cap", fleet: "f", host: "dev", by: "sysop", target: "f", ceiling: { approvals: "native", depth: 3 } }; + const other = { ...cap, at: "2026-09-13T05:20:00Z", target: "some-swarm", ceiling: { depth: 0 } }; + assert.deepEqual(fleetCeiling([open, other, cap], "f", { host: "dev" }).ceiling, { approvals: "native", depth: 3, hosts: ["dev"] }, "the latest cap on the fleet wins whole"); +}); + +test("the effective ceiling merges the fleet's with every spawn on the path, a cap on a swarm applied last", () => { + const lines = [ + { at: "2026-09-13T05:00:00Z", event: "fleet.open", fleet: "f", host: "dev", by: "sysop", sysop: "s", ceiling: { approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] } }, + { at: "2026-09-13T05:41:01Z", event: "swarm.spawn", fleet: "f", host: "dev", by: "root", swarm: "outer-0541", task: "t", ceiling: { fan_out: 4, until: "2026-09-13T06:11:01Z" }, pieces: [] }, + { at: "2026-09-13T05:45:00Z", event: "swarm.spawn", fleet: "f", host: "dev", by: "outer-0541-1", swarm: "inner-0545", parent_swarm: "outer-0541", task: "t2", ceiling: { fan_out: 2 }, pieces: [] }, + { at: "2026-09-13T05:50:00Z", event: "fleet.cap", fleet: "f", host: "dev", by: "sysop", target: "outer-0541", ceiling: { approvals: "native" } }, + ]; + assert.deepEqual(swarmChain(lines, "inner-0545").map((l) => l.swarm), ["outer-0541", "inner-0545"]); + assert.deepEqual(effectiveCeiling(lines, "f", { swarm: "inner-0545", host: "dev" }), { + approvals: "native", depth: 3, hosts: ["dev", "netcup"], fan_out: 2, until: "2026-09-13T06:11:01Z", + }); + assert.deepEqual(effectiveCeiling(lines, "f", { host: "dev" }), { approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] }); + // The implicit fleet: approvals enter at the root, from the flags it was started with. + assert.deepEqual(effectiveCeiling([], "anthony@dev", { rootApprovals: "bypass", host: "dev" }), { depth: 1, hosts: ["dev"], approvals: "bypass" }); +}); + +/* ------------------------------------------------------------- the caller */ + +test("context resolves the fleet from the record, else OPENFLEET_FLEET, else current, else the implicit fleet", () => { + withHome(() => { + const none = context(); + assert.equal(none.fleet, implicitFleet()); + assert.equal(none.sysop, implicitFleet()); + assert.equal(none.member, null); + assert.equal(none.agent, false); + assert.equal(none.by, "sysop"); + assert.equal(none.opened, false); + assert.equal(none.ceiling.approvals, undefined, "by hand, in the implicit fleet, the root supplies its own approvals"); + + writeCurrent("team-20260913"); + append("team-20260913", { event: "fleet.open", by: "sysop", sysop: "https://example.com/anthony.md", ceiling: { approvals: "native", depth: 2 } }, { host: "dev" }); + const current = context(); + assert.equal(current.fleet, "team-20260913"); + assert.equal(current.sysop, "https://example.com/anthony.md"); + assert.equal(current.opened, true); + assert.equal(current.ceiling.approvals, "native"); + + process.env.OPENFLEET_FLEET = "other"; + assert.equal(context().fleet, "other", "OPENFLEET_FLEET overrides current"); + + const record = { openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "460a4502", depth: 0, approvals: "bypass", ceiling: { approvals: "bypass", depth: 1, hosts: ["dev"] } }; + const written = writeRecord(record); + process.env.OPENFLEET_RECORD = written.path; + process.env.OPENFLEET_MEMBER = "460a4502"; + const own = context(); + assert.equal(own.fleet, "anthony@dev", "the record wins over OPENFLEET_FLEET"); + assert.equal(own.member, "460a4502"); + assert.equal(own.agent, true); + assert.equal(own.by, "460a4502"); + assert.equal(own.depth, 0); + assert.deepEqual(own.ceiling, { approvals: "bypass", depth: 1, hosts: ["dev"] }); + }); +}); + +test("inside a member of the implicit fleet, approvals come from the root of the parent chain", () => { + withHome(() => { + writeRecord({ openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "root", depth: 0, approvals: "native" }); + const child = writeRecord({ openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "root-1-1", parent: "root", swarm: "root-1", depth: 1, approvals: "bypass", ceiling: { approvals: "bypass", depth: 1 } }); + process.env.OPENFLEET_RECORD = child.path; + assert.equal(context().ceiling.approvals, "native", "the root was started native, whatever a child's record copied"); + const orphan = writeRecord({ openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "stray", orphan: true, approvals: "bypass" }); + process.env.OPENFLEET_RECORD = orphan.path; + assert.equal(context().ceiling.approvals, "native", "a root that carries orphan gets native"); + const missing = writeRecord({ openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "lonely", parent: "nobody", depth: 1, ceiling: { approvals: "bypass", depth: 1 } }); + process.env.OPENFLEET_RECORD = missing.path; + assert.equal(context().ceiling.approvals, "bypass", "with the chain broken, the record's own ceiling is the witness"); + }); +}); + +/* ---------------------------------------------------------------- the ids */ + +test("a swarm id is a short slug of the task and the UTC minute, and its member ids fit the pane name rule", () => { + const now = Date.UTC(2026, 8, 13, 5, 41, 30); + assert.equal(swarmId("create two ...", { now }), "create-two-0541"); + assert.equal(swarmId("Port the auth routes and the dashboard to the new API", { now }), "port-the-auth-routes-an-0541"); + assert.equal(swarmId("anything", { name: "api", now }), "api-0541"); + assert.equal(swarmId("2024 report", { now }), "report-0541", "a leading digit cannot start a pane name"); + assert.equal(swarmId("!!!", { now }), "agent-0541", "the herd's own fallback for a task with no letters"); + for (const task of ["create two ...", "a".repeat(80), "x-y-z-w-v-u-t-s-r-q-p-o-n-m-l-k"]) { + const id = swarmId(task, { now }); + assert.ok(id.length <= 28, `${id} leaves room for -NN`); + assert.match(`${id}-16`, NAME_RE, `${id}-16 must be a valid pane name`); + } + assert.equal(iso(now), "2026-09-13T05:41:30Z"); +}); + +/* --------------------------------------------------------------- the fold */ + +// The spec's worked example: job 460a4502, a root of the implicit fleet, spawns +// create-two-0541 with two pieces; the planner's own call was a swarm of one. +const EXAMPLE_LINES = [ + { at: "2026-09-13T04:55:00Z", event: "member.start", fleet: "anthony@dev", host: "dev", by: "460a4502", member: "460a4502", engine: "claude-code", depth: 0, approvals: "bypass", cwd: "/home/anthony" }, + { at: "2026-09-13T05:40:50Z", event: "swarm.spawn", fleet: "anthony@dev", host: "dev", by: "460a4502", swarm: "460a4502-1", task: 'claude -p "Split the task below into at most 4 ..."', ceiling: {}, pieces: [{ member: "460a4502-1-1" }] }, + { at: "2026-09-13T05:40:51Z", event: "member.start", fleet: "anthony@dev", host: "dev", by: "460a4502-1-1", member: "460a4502-1-1", session: "31337", swarm: "460a4502-1", parent: "460a4502", depth: 1, engine: "claude-p", approvals: "bypass" }, + { at: "2026-09-13T05:41:00Z", event: "member.end", fleet: "anthony@dev", host: "dev", by: "460a4502-1-1", member: "460a4502-1-1", state: "done" }, + { at: "2026-09-13T05:41:00Z", event: "swarm.end", fleet: "anthony@dev", host: "dev", by: "460a4502", swarm: "460a4502-1", state: "done" }, + { at: "2026-09-13T05:41:01Z", event: "swarm.spawn", fleet: "anthony@dev", host: "dev", by: "460a4502", swarm: "create-two-0541", task: "create two ...", ceiling: { fan_out: 4, until: "2026-09-13T06:11:01Z" }, pieces: [{ member: "create-two-0541-1", title: "create hello.sh bash", owns: ["hello.sh"] }, { member: "create-two-0541-2", title: "create bye.sh bash", owns: ["bye.sh"] }] }, + { at: "2026-09-13T05:41:12Z", event: "member.start", fleet: "anthony@dev", host: "dev", by: "create-two-0541-1", member: "create-two-0541-1", session: "172ffd83", swarm: "create-two-0541", parent: "460a4502", depth: 1, engine: "claude-code", cwd: "/x", approvals: "bypass", piece: { title: "create hello.sh bash", owns: ["hello.sh"] } }, + { at: "2026-09-13T05:41:13Z", event: "member.start", fleet: "anthony@dev", host: "dev", by: "460a4502", member: "create-two-0541-2", session: "create-two-0541-2", swarm: "create-two-0541", parent: "460a4502", depth: 1, engine: "moshcode/claude", cwd: "/x", approvals: "bypass", piece: { title: "create bye.sh bash", owns: ["bye.sh"] } }, + { at: "2026-09-13T05:41:30Z", event: "member.spend", fleet: "anthony@dev", host: "dev", by: "create-two-0541-1", member: "create-two-0541-1", amount: "1200 tokens", total: "1200 tokens" }, + { at: "2026-09-13T05:41:36Z", event: "member.end", fleet: "anthony@dev", host: "dev", by: "create-two-0541-1", member: "create-two-0541-1", state: "done", summary: "Created hello.sh, mode -rwxrwxr-x, prints hello." }, +]; +const EXAMPLE_RECORDS = [ + { openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "460a4502", engine: "claude-code", depth: 0, approvals: "bypass", host: "dev", started: "2026-09-13T04:55:00Z", ceiling: { approvals: "bypass", depth: 1, hosts: ["dev"] } }, + { openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "create-two-0541-1", parent: "460a4502", swarm: "create-two-0541", task: "create two ...", piece: { title: "create hello.sh bash", owns: ["hello.sh"] }, depth: 1, engine: "claude-code", host: "dev", cwd: "/x", started: "2026-09-13T05:41:01Z", approvals: "bypass", ceiling: { approvals: "bypass", depth: 1, fan_out: 4, hosts: ["dev"], until: "2026-09-13T06:11:01Z" } }, + { openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "create-two-0541-2", parent: "460a4502", swarm: "create-two-0541", task: "create two ...", piece: { title: "create bye.sh bash", owns: ["bye.sh"] }, depth: 1, engine: "moshcode/claude", session: "create-two-0541-2", host: "dev", cwd: "/x", started: "2026-09-13T05:41:01Z", approvals: "bypass", ceiling: { approvals: "bypass", depth: 1, fan_out: 4, hosts: ["dev"], until: "2026-09-13T06:11:01Z" } }, +]; + +test("fold turns the worked example into the tree on the landing page", () => { + const model = fold({ fleets: [{ fleet: "anthony@dev", lines: EXAMPLE_LINES, records: EXAMPLE_RECORDS }], host: "dev", implicit: "anthony@dev", now: Date.UTC(2026, 8, 13, 5, 42, 0) }); + assert.equal(model.fleets.length, 1); + const f = model.fleets[0]; + assert.equal(f.implicit, true); + assert.equal(f.sysop, "anthony@dev"); + assert.deepEqual(f.ceiling, { depth: 1, hosts: ["dev"] }); + assert.equal(f.spend, "1200 tokens"); + assert.deepEqual(f.nodes.map((n) => n.kind), ["member"], "one root, and the swarms hang under it"); + const root = f.nodes[0]; + assert.equal(root.member, "460a4502"); + assert.equal(root.state, "running"); + assert.equal(root.approvals, "bypass"); + assert.deepEqual(root.swarms.map((s) => s.swarm), ["460a4502-1", "create-two-0541"]); + const [planner, swarm] = root.swarms; + assert.equal(planner.state, "done"); + assert.equal(planner.members[0].engine, "claude-p"); + assert.equal(planner.members[0].session, "31337"); + assert.equal(swarm.state, "running"); + assert.equal(swarm.fan_out, 4); + assert.equal(swarm.until, "2026-09-13T06:11:01Z"); + assert.equal(swarm.pieces, 2); + assert.deepEqual(swarm.members.map((m) => m.member), ["create-two-0541-1", "create-two-0541-2"], "in piece order"); + const [one, two] = swarm.members; + assert.equal(one.session, "172ffd83"); + assert.equal(one.state, "done"); + assert.equal(one.spend, "1200 tokens"); + assert.deepEqual(one.owns, ["hello.sh"]); + assert.equal(one.title, "create hello.sh bash"); + assert.equal(two.state, "running"); + assert.equal(two.engine, "moshcode/claude"); + assert.equal(two.claimed, true); + assert.equal(swarm.spend, "1200 tokens"); + + const text = renderTree(model, { host: "dev" }); + const lines = text.split("\n"); + assert.equal(lines[0], "anthony@dev (implicit fleet, sysop anthony@dev, depth 1, hosts dev, spent 1200 tokens)"); + assert.match(lines[1], /^└─ 460a4502 claude-code running \[bypass\]$/); + assert.match(lines[2], /^ ├─ swarm 460a4502-1 "claude -p "Split the task below into \.\.\." 1 member done$/, "a long task is clipped to 40 characters"); + assert.match(lines[3], /^ │ └─ 460a4502-1-1 \(31337\) claude-p done \[bypass\]$/); + assert.match(lines[4], /^ └─ swarm create-two-0541 "create two \.\.\." 2\/4 members until 06:11 spent 1200 tokens$/); + assert.match(lines[5], /^ ├─ create-two-0541-1 \(172ffd83\) create hello\.sh bash claude-code done \[bypass\] owns hello\.sh spent 1200 tokens$/); + assert.match(lines[6], /^ └─ create-two-0541-2 create bye\.sh bash moshcode\/claude running \[bypass\] owns bye\.sh$/); + assert.equal(lines.length, 7); +}); + +test("fold joins the herd roster: liveness for moshcode members, lost for a claimed pane the roster dropped, roster-only roots", () => { + const live = fold({ + fleets: [{ fleet: "anthony@dev", lines: EXAMPLE_LINES, records: EXAMPLE_RECORDS }], + roster: [ + { name: "create-two-0541-2", engine: "moshcode/claude", state: "working", alive: true, approvals: "bypass" }, + { name: "shell-1", engine: "moshcode/shell", state: "idle", alive: true, approvals: "native", cwd: "/home/anthony/src" }, + ], + host: "dev", implicit: "anthony@dev", + }); + const swarm = live.fleets[0].nodes[0].swarms[1]; + assert.equal(swarm.members[1].state, "working", "the roster's state, since the member is claimed and has no end line"); + assert.equal(swarm.members[1].live, true); + assert.equal(swarm.members[1].lost, false); + assert.equal(swarm.members[0].live, null, "a claude-code member is not in the herd roster; no liveness claimed"); + const rosterOnly = live.fleets[0].nodes.find((n) => n.member === "shell-1"); + assert.ok(rosterOnly, "a herd session with no record is a root of the implicit fleet"); + assert.equal(rosterOnly.rosterOnly, true); + assert.equal(rosterOnly.depth, 0); + assert.match(renderTree(live, { host: "dev" }), /shell-1 \/home\/anthony\/src moshcode\/shell idle \[roster\]/); + + const dropped = fold({ fleets: [{ fleet: "anthony@dev", lines: EXAMPLE_LINES, records: EXAMPLE_RECORDS }], roster: [], host: "dev", implicit: "anthony@dev" }); + const gone = dropped.fleets[0].nodes[0].swarms[1].members[1]; + assert.equal(gone.state, "lost"); + assert.equal(gone.lost, true, "flagged so the tool writes the member.end"); + assert.equal(dropped.fleets.length, 1, "an empty roster invents no implicit fleet entry"); + assert.equal(dropped.fleets[0].nodes[0].swarms[1].members[0].lost, false, "an ended member is not lost"); + + const noFleet = fold({ fleets: [], roster: [{ name: "api", engine: "moshcode/claude", state: "idle", alive: true, approvals: "bypass" }], host: "dev", implicit: "anthony@dev" }); + assert.equal(noFleet.fleets.length, 1); + assert.equal(noFleet.fleets[0].implicit, true); + assert.equal(noFleet.fleets[0].nodes[0].rosterOnly, true); +}); + +test("a swarm the sysop started by hand hangs under the fleet; an unclaimed record reads unclaimed; a swarm with no spawn line is drawn anyway", () => { + const lines = [ + { at: "2026-09-13T05:41:01Z", event: "swarm.spawn", fleet: "anthony@dev", host: "dev", by: "sysop", swarm: "byhand-0541", task: "t", ceiling: {}, pieces: [{ member: "byhand-0541-1" }] }, + { at: "2026-09-13T05:41:02Z", event: "ceiling.refuse", fleet: "anthony@dev", host: "dev", by: "sysop", action: "spawn", key: "depth", wanted: 2, allowed: 1 }, + ]; + const records = [ + { openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "byhand-0541-1", swarm: "byhand-0541", depth: 0, engine: "moshcode/codex", approvals: "native" }, + { openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "mystery-1", swarm: "mystery", parent: "nobody", depth: 1, engine: "moshcode/kimi" }, + ]; + const model = fold({ fleets: [{ fleet: "anthony@dev", lines, records }], host: "dev", implicit: "anthony@dev" }); + const f = model.fleets[0]; + assert.deepEqual(f.nodes.map((n) => `${n.kind}:${n.swarm}`), ["swarm:byhand-0541", "swarm:mystery"]); + assert.equal(f.nodes[0].by, "sysop"); + assert.equal(f.nodes[0].members[0].state, "unclaimed"); + assert.equal(f.nodes[1].missing, true); + assert.equal(f.refusals.length, 1); + assert.match(renderTree(model, { host: "dev" }), /\[no swarm\.spawn\]/); + assert.equal(sumSpend(["1 USD", "2 USD", "5 tokens", "junk"]), "3 USD + 5 tokens"); + assert.equal(sumSpend([]), null); +}); diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs index 6b744d30..57bc0ed4 100644 --- a/test/swarm.test.mjs +++ b/test/swarm.test.mjs @@ -1,14 +1,21 @@ -// Swarm (PRD 0015): the flag grammar, the lenient readers of what a model -// says, the gate that keeps a swarm to N at a time, and the four phases run -// against fakes — no tmux, no model. +// Swarm (PRD 0015, PRD 0016): the flag grammar, the lenient readers of what a +// model says, the gate that keeps a swarm to N at a time, the four phases run +// against fakes (no tmux, no model), and the record every swarm now leaves +// behind: the swarm id, one record per member, and the ledger lines in order. +// The fleet's files are real, under a mkdtemp OPENFLEET_HOME per test. import test from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { - DEFAULT_AGENTS, MAX_AGENTS, bootAnswer, parsePlan, parseSwarmArgs, parseVerdict, planPrompt, runSwarm, - swarmCommand, swarmPrefix, synthesisPrompt, throttled, verifyPrompt, waitForPrompt, + DEFAULT_AGENTS, MAX_AGENTS, bootAnswer, endStateOf, parsePlan, parseSwarmArgs, parseVerdict, planPrompt, runSwarm, + summaryOf, swarmCommand, swarmId, synthesisPrompt, throttled, verifyPrompt, waitForPrompt, } from "../src/swarm.mjs"; import { ENGINES } from "../src/engines.mjs"; +import { NAME_RE } from "../src/herd.mjs"; +import * as openfleet from "../src/openfleet.mjs"; /* -------------------------------------------------------------- the flags */ @@ -46,12 +53,17 @@ test("--timeout takes the herd's durations", () => { assert.equal(parseSwarmArgs(["x", "--timeout=90s"]).timeoutMs, 90000); }); -test("session names come from the task and fit the herd's name rule", () => { - const prefix = swarmPrefix("Port the auth routes and the dashboard to the new API"); - assert.match(prefix, /^swarm-[a-z0-9-]+$/); - assert.ok(prefix.length + 3 <= 32, "prefix plus -NN must fit NAME_RE"); - assert.equal(swarmPrefix("anything", { name: "api" }), "swarm-api"); - assert.equal(swarmPrefix("!!!"), "swarm-agent", "the herd's own fallback name for a task with no letters"); +test("the swarm id comes from the task and the minute, and member ids fit the herd's name rule", () => { + // The member id is the pane name (PRD 0016): `-` has to satisfy + // NAME_RE with two digits to spare, so the swarm id stays short. + const now = Date.UTC(2026, 8, 13, 5, 41, 0); + const id = swarmId("Port the auth routes and the dashboard to the new API", { now }); + assert.match(id, /^[a-z][a-z0-9-]*-0541$/); + assert.ok(id.length + 3 <= 32, "id plus -NN must fit NAME_RE"); + assert.match(`${id}-16`, NAME_RE); + assert.equal(swarmId("anything", { name: "api", now }), "api-0541"); + assert.equal(swarmId("!!!", { now }), "agent-0541", "the herd's own fallback name for a task with no letters"); + assert.equal(swarmId("create two ...", { now }), "create-two-0541", "the spec's own example"); }); test("claude's trust dialog is answered with Down then Enter, never a bare Enter", () => { @@ -104,6 +116,20 @@ test("the plan is the first JSON array in the reply, whatever surrounds it", () assert.deepEqual(parsePlan(reply), [{ title: "a", prompt: "do a" }, { title: "b", prompt: "do b" }]); }); +test("a plan keeps the files a piece owns, as data, and only when the planner gave some", () => { + const reply = JSON.stringify([ + { title: "a", prompt: "do a", files: ["hello.sh", " lib/a.js ", "", 7] }, + { title: "b", prompt: "do b", files: [] }, + { title: "c", prompt: "do c" }, + ]); + assert.deepEqual(parsePlan(reply), [ + { title: "a", prompt: "do a", files: ["hello.sh", "lib/a.js"] }, + { title: "b", prompt: "do b" }, + { title: "c", prompt: "do c" }, + ]); + assert.match(planPrompt({ task: "T", agents: 2, cwd: "/x" }), /"files": \[/, "the planner is asked for them"); +}); + test("a plan is capped at --agents and drops entries with no prompt", () => { const reply = JSON.stringify([{ title: "a", prompt: "x" }, { title: "b" }, { prompt: "y" }, { title: "d", prompt: "z" }]); const plan = parsePlan(reply, { agents: 2 }); @@ -124,6 +150,18 @@ test("a verdict is read leniently and defaults to unknown", () => { assert.equal(parseVerdict('{"reason":"hmm"}').refuted, null); }); +test("a member's summary is its SUMMARY: section when it wrote one, else the tail", () => { + assert.equal(summaryOf("did things\nSUMMARY: wrote hello.sh, it prints hello"), "wrote hello.sh, it prints hello"); + assert.equal(summaryOf("first SUMMARY: no\nlater\nSummary: the last one counts"), "the last one counts"); + assert.equal(summaryOf("x".repeat(600), { max: 500 }).length, 500); + assert.equal(summaryOf(""), ""); + assert.equal(endStateOf({ outcome: "matched" }), "done"); + assert.equal(endStateOf({ outcome: "timeout" }), "timeout"); + assert.equal(endStateOf({ outcome: "gone" }), "lost"); + assert.equal(endStateOf({ outcome: "failed" }), "failed"); + assert.equal(endStateOf({ outcome: "ended" }), "failed"); +}); + /* ------------------------------------------------------------ the prompts */ test("the planning prompt asks for pieces that do not collide, as bare JSON", () => { @@ -179,128 +217,402 @@ test("throttled runs at most N at a time and keeps order", async () => { /* ------------------------------------------------------------- the phases */ -function fakes({ plan, verdict = { refuted: false, reason: "fine" }, synthesis = "THE ANSWER", failStart = [], neverReady = [] } = {}) { - const calls = { ai: [], start: [], boot: [], prompt: [], kill: [] }; +// One minute, so the swarm id and every member id are known: `T` at 05:41 +// UTC is swarm `t-0541`, members `t-0541-1`, `t-0541-2`, ... The clock ticks +// 100ms per look, because the ledger is sorted by `at` and a real run's lines +// are written in the order they happen. +const NOW = Date.UTC(2026, 8, 13, 5, 41, 0); +const SWARM = "t-0541"; +const m = (n) => `${SWARM}-${n}`; +let clock = NOW; +const now = () => { clock += 100; return clock; }; + +const OPENFLEET_VARS = ["OPENFLEET_HOME", "OPENFLEET_RECORD", "OPENFLEET_FLEET", "OPENFLEET_MEMBER", "OPENFLEET_SWARM"]; + +/** A fresh fleet home per test; the module reads the env var on every call. */ +async function inHome(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-swarm-test-")); + const previous = Object.fromEntries(OPENFLEET_VARS.map((k) => [k, process.env[k]])); + for (const k of OPENFLEET_VARS) delete process.env[k]; + process.env.OPENFLEET_HOME = dir; + clock = NOW; + try { return await fn(dir); } + finally { + for (const k of OPENFLEET_VARS) { + if (previous[k] === undefined) delete process.env[k]; + else process.env[k] = previous[k]; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const fleetOf = () => openfleet.implicitFleet(); +const ledger = () => openfleet.readLedger(fleetOf()); +const events = () => ledger().map((l) => l.event); + +function fakes({ plan, verdict = { refuted: false, reason: "fine" }, synthesis = "THE ANSWER", failStart = [], neverReady = [], artifact = (name) => `output of ${name}` } = {}) { + const calls = { ai: [], start: [], boot: [], prompt: [], kill: [], killSeen: [] }; let running = 0, peak = 0; const deps = { - ai: (engine, prompt) => { - calls.ai.push({ engine, prompt }); + ai: (engine, prompt, options = {}) => { + calls.ai.push({ engine, prompt, options }); if (prompt.startsWith("You are planning")) return typeof plan === "string" ? plan : JSON.stringify(plan); if (prompt.startsWith("You are a skeptical")) return JSON.stringify(verdict); return synthesis; }, - start: (name, opts) => { calls.start.push({ name, ...opts }); return failStart.includes(name) ? { ok: false, error: "tmux said no" } : { ok: true }; }, + // Each start notes what the ledger already held, so a test can say what + // was written before the first member began. + start: (name, opts) => { calls.start.push({ name, ...opts, seen: events() }); return failStart.includes(name) ? { ok: false, error: "tmux said no" } : { ok: true }; }, boot: async (name) => { calls.boot.push(name); return neverReady.includes(name) ? { outcome: "timeout", state: "working" } : { outcome: "matched", state: "idle" }; }, - prompt: async (name, text) => { + prompt: async (name, text, { onSubmitted } = {}) => { running++; peak = Math.max(peak, running); + onSubmitted?.({ at: now(), task: `t-${name}` }); await new Promise((r) => setTimeout(r, 5)); running--; calls.prompt.push({ name, text }); - return { ok: true, task: `t-${name}`, outcome: "matched", state: "done", artifact: `output of ${name}`, error: null }; + return { ok: true, task: `t-${name}`, outcome: "matched", state: "done", artifact: artifact(name), error: null }; }, - kill: async (name) => { calls.kill.push(name); }, + kill: async (name) => { calls.kill.push(name); calls.killSeen.push(events()); }, }; return { deps, calls, peak: () => peak }; } const engineOf = () => "claude"; +const run = (options, { deps }, extra = {}) => runSwarm( + { task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000, ...options }, + { deps, engineOf, now, ...extra }, +); + +/** A parent record for moshcode to run inside, claimed, with its effective ceiling. */ +function insideMember({ member = "460a4502", depth = 0, approvals = "bypass", ceiling = { approvals, depth: 1, hosts: [openfleet.host()] }, swarm = null } = {}) { + const record = { openfleet: "0.1", fleet: fleetOf(), sysop: fleetOf(), member, ...(swarm ? { swarm } : {}), depth, engine: "claude-code", approvals, ceiling }; + const written = openfleet.writeRecord(record); + openfleet.append(fleetOf(), { event: "member.start", by: member, member, engine: "claude-code", depth, approvals }); + process.env.OPENFLEET_RECORD = written.path; + process.env.OPENFLEET_FLEET = fleetOf(); + process.env.OPENFLEET_MEMBER = member; + return record; +} -test("a swarm plans, fans out, kills its sessions, and synthesises", async () => { - const { deps, calls } = fakes({ plan: [{ title: "a", prompt: "do a\nthen b" }, { title: "b", prompt: "do b" }] }); +test("a swarm plans, fans out, kills its sessions, and synthesises", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "do a\nthen b" }, { title: "b", prompt: "do b" }] }); const lines = []; - const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { write: (l) => lines.push(l), deps, engineOf }); + const result = await run({}, f, { write: (l) => lines.push(l) }); assert.equal(result.ok, true); assert.equal(result.engine, "claude"); + assert.equal(result.swarm, SWARM); + assert.equal(result.fleet, fleetOf()); assert.deepEqual(result.plan.map((p) => p.title), ["a", "b"]); - assert.deepEqual(calls.start.map((s) => s.name), ["swarm-t-1", "swarm-t-2"]); - assert.equal(calls.start[0].herd, "swarm"); - assert.equal(calls.start[0].cwd, "/x"); - assert.equal(calls.prompt[0].text, "do a then b", "a newline in a prompt would submit it early"); - assert.deepEqual(calls.kill, ["swarm-t-1", "swarm-t-2"], "sessions are ended when the swarm is done"); - assert.equal(result.results[1].task, "t-swarm-t-2"); - assert.equal(result.results[1].artifact, "output of swarm-t-2"); + assert.deepEqual(f.calls.start.map((s) => s.name), [m(1), m(2)], "panes are named after their member ids"); + assert.equal(f.calls.start[0].herd, "swarm"); + assert.equal(f.calls.start[0].cwd, "/x"); + assert.equal(f.calls.prompt[0].text, "do a then b", "a newline in a prompt would submit it early"); + assert.deepEqual(f.calls.kill, [m(1), m(2)], "sessions are ended when the swarm is done"); + assert.equal(result.results[1].task, `t-${m(2)}`); + assert.equal(result.results[1].member, m(2)); + assert.equal(result.results[1].session, m(2)); + assert.equal(result.results[1].artifact, `output of ${m(2)}`); assert.equal(result.synthesis, "THE ANSWER"); - const synth = calls.ai.at(-1).prompt; - assert.match(synth, /output of swarm-t-1/); - assert.match(synth, /output of swarm-t-2/); - assert.equal(calls.ai.length, 2, "plan, synthesis — and no verifier unless asked"); -}); + const synth = f.calls.ai.at(-1).prompt; + assert.match(synth, new RegExp(`output of ${m(1)}`)); + assert.match(synth, new RegExp(`output of ${m(2)}`)); + assert.equal(f.calls.ai.length, 2, "plan, synthesis — and no verifier unless asked"); +})); + +test("the record of a swarm: spawn before the first start, one record and the four variables per member, start on submit, ends before the kills", () => inHome(async (home) => { + const f = fakes({ + plan: [{ title: "a", prompt: "do a", files: ["a.js"] }, { title: "b", prompt: "do b" }], + artifact: (name) => `worked on ${name}\nSUMMARY: ${name} is done`, + }); + const result = await run({}, f); + const fleet = fleetOf(); + const host = openfleet.host(); + + // swarm.spawn, by the sysop since moshcode ran by hand, before any start. + const spawn = ledger().find((l) => l.event === "swarm.spawn"); + assert.ok(spawn, "swarm.spawn was written"); + assert.deepEqual(f.calls.start[0].seen, ["swarm.spawn"], "it precedes the first member start"); + assert.equal(spawn.by, "sysop"); + assert.equal(spawn.fleet, fleet); + assert.equal(spawn.host, host); + assert.equal(spawn.swarm, SWARM); + assert.equal(spawn.task, "T"); + assert.equal(spawn.parent_swarm, undefined, "no parent swarm when the sysop starts it by hand"); + const until = openfleet.iso(Date.parse(spawn.at) + 1000); + assert.deepEqual(spawn.ceiling, { fan_out: 4, until }, "the narrowing from --agents and --timeout, from the moment of the spawn"); + assert.deepEqual(spawn.pieces, [{ member: m(1), title: "a", owns: ["a.js"] }, { member: m(2), title: "b" }]); + + // One unclaimed record per member, with exactly the spec's keys. + const record = openfleet.readMember(fleet, m(1)); + assert.deepEqual(Object.keys(record), ["openfleet", "fleet", "sysop", "member", "swarm", "task", "piece", "depth", "engine", "session", "host", "cwd", "started", "approvals", "ceiling"]); + assert.equal(record.openfleet, "0.1"); + assert.equal(record.sysop, fleet); + assert.equal(record.parent, undefined, "members of a sysop-started swarm have no parent"); + assert.equal(record.depth, 0); + assert.deepEqual(record.piece, { title: "a", owns: ["a.js"] }); + assert.deepEqual(openfleet.readMember(fleet, m(2)).piece, { title: "b" }, "no owns when the planner gave no files"); + assert.equal(record.engine, "moshcode/claude"); + assert.equal(record.session, m(1), "the pane name is the tmux target"); + assert.equal(record.cwd, "/x"); + assert.equal(record.approvals, "bypass", "truthful: the pane runs claude --dangerously-skip-permissions"); + assert.deepEqual(record.ceiling, { depth: 1, hosts: [host], fan_out: 4, until }); + assert.equal(fs.statSync(openfleet.recordPath(fleet, m(1))).mode & 0o777, 0o600); + + // The four variables, plus the home, reach the pane. + assert.deepEqual(f.calls.start[0].env, { + OPENFLEET_HOME: home, OPENFLEET_RECORD: openfleet.recordPath(fleet, m(1)), OPENFLEET_FLEET: fleet, OPENFLEET_MEMBER: m(1), OPENFLEET_SWARM: SWARM, + }); -test("--agents caps the plan as well as gating the sessions", async () => { + // member.start on behalf of the pane, at the submit moment. + const starts = ledger().filter((l) => l.event === "member.start"); + assert.deepEqual(starts.map((l) => l.member), [m(1), m(2)]); + assert.ok(starts[0].at >= spawn.at, "at is the submit moment, at or after the spawn (the sort is stable within a second)"); + assert.equal(starts[0].by, "sysop", "the starter wrote it, not the session"); + assert.equal(starts[0].session, m(1)); + assert.equal(starts[0].swarm, SWARM); + assert.equal(starts[0].engine, "moshcode/claude"); + assert.equal(starts[0].approvals, "bypass"); + assert.deepEqual(starts[0].piece, { title: "a", owns: ["a.js"] }); + + // member.end for each, then one swarm.end with the synthesis, then the kills. + const ends = ledger().filter((l) => l.event === "member.end"); + assert.deepEqual(ends.map((l) => [l.member, l.state, l.summary]), [[m(1), "done", `${m(1)} is done`], [m(2), "done", `${m(2)} is done`]]); + assert.ok(ends[0].at >= starts[1].at, "and the ends come after every start"); + const end = ledger().find((l) => l.event === "swarm.end"); + assert.equal(end.state, "done"); + assert.equal(end.summary, "THE ANSWER"); + assert.equal(end.verdict, undefined, "no verdict unless --verify ran"); + assert.equal(end.by, "sysop"); + assert.deepEqual(events(), ["swarm.spawn", "member.start", "member.start", "member.end", "member.end", "swarm.end"]); + assert.deepEqual(f.calls.killSeen[0], events(), "every line was written before the first kill"); + assert.equal(result.ok, true); +})); + +test("inside a member, the swarm's parent is that member and the members sit one deeper", () => inHome(async () => { + insideMember({ member: "460a4502", depth: 0, approvals: "bypass" }); + const f = fakes({ plan: [{ title: "a", prompt: "a" }] }); + const result = await run({}, f); + assert.equal(result.ok, true); + const spawn = ledger().find((l) => l.event === "swarm.spawn"); + assert.equal(spawn.by, "460a4502"); + assert.equal(spawn.parent_swarm, undefined, "the parent is a root, in no swarm"); + const record = openfleet.readMember(fleetOf(), m(1)); + assert.equal(record.parent, "460a4502"); + assert.equal(record.depth, 1); + assert.equal(record.ceiling.approvals, "bypass", "the root's approvals reach its subtree"); + const start = ledger().find((l) => l.event === "member.start" && l.member === m(1)); + assert.equal(start.by, "460a4502"); + assert.equal(start.parent, "460a4502"); + assert.equal(start.depth, 1); + assert.equal(ledger().find((l) => l.event === "swarm.end").by, "460a4502"); + assert.equal(f.calls.start[0].env.OPENFLEET_MEMBER, m(1)); +})); + +test("inside a member that is itself in a swarm, parent_swarm names it", () => inHome(async () => { + openfleet.append(fleetOf(), { event: "swarm.spawn", by: "root", swarm: "outer-0500", task: "outer", ceiling: {}, pieces: [{ member: "outer-0500-1" }] }); + insideMember({ member: "outer-0500-1", depth: 0, approvals: "bypass", ceiling: { approvals: "bypass", depth: 2, hosts: [openfleet.host()] }, swarm: "outer-0500" }); + const f = fakes({ plan: [{ title: "a", prompt: "a" }] }); + await run({}, f); + const spawn = ledger().find((l) => l.event === "swarm.spawn" && l.swarm === SWARM); + assert.equal(spawn.parent_swarm, "outer-0500"); + assert.equal(spawn.by, "outer-0500-1"); +})); + +test("a bypass flag under a native parent is refused with ceiling.refuse, and nothing else is written or started", () => inHome(async () => { + insideMember({ member: "460a4502", depth: 0, approvals: "native" }); + const f = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] }); + const lines = []; + const result = await run({}, f, { write: (l) => lines.push(l) }); + assert.equal(result.ok, false); + assert.match(result.error, /refuses approvals/); + assert.deepEqual(result.refused, { key: "approvals", wanted: "bypass", allowed: "native" }); + assert.deepEqual(result.results.map((r) => [r.member, r.state, r.outcome]), [[m(1), "failed", "refused"], [m(2), "failed", "refused"]]); + assert.deepEqual(f.calls.start, [], "no member starts"); + assert.deepEqual(f.calls.kill, []); + assert.equal(f.calls.ai.length, 1, "the plan ran; the synthesis has nothing to fold"); + const refusals = ledger().filter((l) => l.event === "ceiling.refuse"); + assert.deepEqual(refusals.map((l) => [l.member, l.action, l.key, l.wanted, l.allowed, l.by]), [ + [m(1), "start", "approvals", "bypass", "native", "460a4502"], + [m(2), "start", "approvals", "bypass", "native", "460a4502"], + ]); + assert.deepEqual(events().filter((e) => e !== "member.start" && e !== "ceiling.refuse"), [], "no swarm.spawn, no ends"); + assert.equal(openfleet.readMember(fleetOf(), m(1)), null, "no record"); + assert.ok(lines.some((l) => /refuses approvals/.test(l))); +})); + +test("a swarm that would sit too deep is refused on depth, once, before anything is written", () => inHome(async () => { + insideMember({ member: "460a4502-1-1", depth: 1, approvals: "bypass" }); + const f = fakes({ plan: [{ title: "a", prompt: "a" }] }); + const result = await run({}, f); + assert.equal(result.ok, false); + assert.deepEqual(result.refused, { key: "depth", wanted: 2, allowed: 1 }); + const refusals = ledger().filter((l) => l.event === "ceiling.refuse"); + assert.equal(refusals.length, 1, "a swarm-wide key refuses the spawn itself, once"); + assert.equal(refusals[0].action, "spawn"); + assert.equal(refusals[0].member, undefined, "no record exists to name"); + assert.equal(refusals[0].by, "460a4502-1-1"); + assert.deepEqual(f.calls.start, []); +})); + +test("run by hand in the implicit fleet, the bypass flag is the sysop's own choice and passes", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "a" }] }); + const result = await run({}, f); + assert.equal(result.ok, true); + assert.equal(ledger().filter((l) => l.event === "ceiling.refuse").length, 0); + assert.equal(openfleet.readMember(fleetOf(), m(1)).ceiling.approvals, undefined, "no fleet-level approvals in the implicit fleet"); +})); + +test("an opened fleet's ceiling applies: native means the bypass flag is refused, and fan_out caps the plan", () => inHome(async () => { + openfleet.writeCurrent("team-20260913"); + openfleet.append("team-20260913", { event: "fleet.open", by: "sysop", sysop: "anthony@dev", ceiling: { approvals: "bypass", depth: 2, fan_out: 2 } }); + const f = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }, { title: "c", prompt: "c" }] }); + const result = await run({}, f); + assert.equal(result.fleet, "team-20260913", "current names the fleet new roots join"); + assert.equal(result.ok, false); + assert.deepEqual(result.refused, { key: "fan_out", wanted: 3, allowed: 2 }); + const [refusal] = openfleet.readLedger("team-20260913").filter((l) => l.event === "ceiling.refuse"); + assert.equal(refusal.by, "sysop"); + const ok = await run({ agents: 2 }, fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] })); + assert.equal(ok.ok, true); + const spawn = openfleet.readLedger("team-20260913").find((l) => l.event === "swarm.spawn"); + assert.deepEqual(spawn.ceiling, { until: openfleet.iso(Date.parse(spawn.at) + 1000) }, "fan_out 2 equals what is inherited, so only until is a narrowing"); + assert.equal(openfleet.readMember("team-20260913", m(1)).sysop, "anthony@dev"); +})); + +test("the planner, the skeptic and the synthesis run without OPENFLEET_SWARM", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "a" }] }); + await run({ verify: true }, f); + assert.equal(f.calls.ai.length, 3); + for (const call of f.calls.ai) assert.deepEqual(call.options.omitEnv, ["OPENFLEET_SWARM"], `${call.prompt.slice(0, 20)} must not carry the swarm`); + assert.equal(f.calls.ai[0].options.cwd, "/x"); +})); + +test("--agents caps the plan as well as gating the sessions", () => inHome(async () => { // A model that answers with more pieces than it was asked for does not get // to run more agents than the operator allowed. const plan = Array.from({ length: 6 }, (_, i) => ({ title: `p${i}`, prompt: `do ${i}` })); const f = fakes({ plan }); - const result = await runSwarm({ task: "T", agents: 2, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { deps: f.deps, engineOf }); + const result = await run({ agents: 2 }, f); assert.equal(result.results.length, 2); assert.ok(f.peak() <= 2); -}); - -test("a plan the model fluffs becomes one piece holding the whole task, and says so", async () => { - const { deps, calls } = fakes({ plan: "I would rather not." }); + const spawn = ledger().find((l) => l.event === "swarm.spawn"); + assert.equal(spawn.ceiling.until, openfleet.iso(Date.parse(spawn.at) + 1000), "one batch of two at --agents 2"); +})); + +test("the until narrowing is one --timeout per batch, and a plan capped at --agents is one batch", () => inHome(async () => { + const plan = Array.from({ length: 5 }, (_, i) => ({ title: `p${i}`, prompt: `do ${i}` })); + const result = await run({ agents: 2, timeoutMs: 60000 }, fakes({ plan })); + assert.equal(result.plan.length, 2, "the plan never holds more pieces than --agents"); + const spawn = ledger().find((l) => l.event === "swarm.spawn"); + assert.equal(spawn.ceiling.until, openfleet.iso(Date.parse(spawn.at) + 60000)); +})); + +test("a plan the model fluffs becomes one piece holding the whole task, and says so", () => inHome(async () => { + const f = fakes({ plan: "I would rather not." }); const lines = []; - const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { write: (l) => lines.push(l), deps, engineOf }); + const result = await run({}, f, { write: (l) => lines.push(l) }); assert.equal(result.plan.length, 1); assert.match(result.plan[0].prompt, /^T\b/); assert.match(result.plan[0].prompt, /SUMMARY:/); assert.ok(lines.some((l) => /did not parse/.test(l))); - assert.equal(calls.start.length, 1); -}); + assert.equal(f.calls.start.length, 1); + assert.deepEqual(openfleet.readMember(fleetOf(), m(1)).piece, { title: "the whole task" }); +})); -test("a planning call that throws degrades the same way", async () => { +test("a planning call that throws degrades the same way", () => inHome(async () => { const f = fakes({ plan: [] }); f.deps.ai = (engine, prompt) => { if (prompt.startsWith("You are planning")) throw new Error("boom"); return "S"; }; const lines = []; - const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { write: (l) => lines.push(l), deps: f.deps, engineOf }); + const result = await run({}, f, { write: (l) => lines.push(l) }); assert.equal(result.ok, true); assert.equal(result.plan.length, 1); assert.ok(lines.some((l) => /planning failed \(boom\)/.test(l))); -}); +})); -test("a piece whose session never starts or never boots is failed, not fatal", async () => { - const { deps, calls } = fakes({ +test("a piece whose session never starts or never boots is failed, not fatal, and its end line says so", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }, { title: "c", prompt: "c" }], - failStart: ["swarm-t-1"], neverReady: ["swarm-t-2"], + failStart: [m(1)], neverReady: [m(2)], }); - const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { deps, engineOf }); + const result = await run({}, f); assert.equal(result.ok, true, "the synthesis still ran"); assert.deepEqual(result.results.map((r) => r.state), ["failed", "failed", "done"]); assert.match(result.results[0].error, /tmux said no/); assert.match(result.results[1].error, /never became ready/); - assert.deepEqual(calls.kill, ["swarm-t-2", "swarm-t-3"], "a session that booted but never answered is still ended; one that never started is not"); - assert.match(calls.ai.at(-1).prompt, /piece 1: a \(failed\)/); -}); - -test("--verify attaches a verdict to every piece and shows it to the synthesis", async () => { - const { deps, calls } = fakes({ plan: [{ title: "a", prompt: "a" }], verdict: { refuted: true, reason: "claims a test it never ran" } }); - const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000, verify: true }, { deps, engineOf }); + assert.deepEqual(f.calls.kill, [m(2), m(3)], "a session that booted but never answered is still ended; one that never started is not"); + assert.match(f.calls.ai.at(-1).prompt, /piece 1: a \(failed\)/); + const ends = ledger().filter((l) => l.event === "member.end"); + assert.deepEqual(ends.map((l) => [l.member, l.state]), [[m(1), "failed"], [m(2), "timeout"], [m(3), "done"]]); + assert.match(ends[0].summary, /tmux said no/); + assert.equal(ledger().find((l) => l.event === "swarm.end").state, "failed", "the first failure state among the members"); + assert.deepEqual(events().slice(-4), ["member.end", "member.end", "member.end", "swarm.end"]); +})); + +test("--verify attaches a verdict to every piece, shows it to the synthesis, and writes it into swarm.end", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "a" }], verdict: { refuted: true, reason: "claims a test it never ran" } }); + const result = await run({ verify: true }, f); assert.deepEqual(result.results[0].verified, { refuted: true, reason: "claims a test it never ran" }); - assert.equal(calls.ai.length, 3, "plan, verify, synthesis"); - assert.match(calls.ai[1].prompt, /output of swarm-t-1/); - assert.match(calls.ai.at(-1).prompt, /REFUTED — claims a test it never ran/); -}); - -test("--keep leaves the sessions running", async () => { - const { deps, calls } = fakes({ plan: [{ title: "a", prompt: "a" }] }); - const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000, keep: true }, { deps, engineOf }); - assert.deepEqual(calls.kill, []); + assert.equal(f.calls.ai.length, 3, "plan, verify, synthesis"); + assert.match(f.calls.ai[1].prompt, new RegExp(`output of ${m(1)}`)); + assert.match(f.calls.ai.at(-1).prompt, /REFUTED — claims a test it never ran/); + const end = ledger().find((l) => l.event === "swarm.end"); + assert.deepEqual(end.verdict, [{ member: m(1), refuted: true, reason: "claims a test it never ran" }]); +})); + +test("a session that already wrote its own member.start and member.end is left alone", () => inHome(async () => { + // A claude pane with hooks claims its record and ends it itself; moshcode + // writes neither line twice, and a real end is not followed by another. + const f = fakes({ plan: [{ title: "a", prompt: "a" }] }); + f.deps.prompt = async (name, text, { onSubmitted }) => { + openfleet.append(fleetOf(), { event: "member.start", by: name, member: name, session: "172ffd83", swarm: SWARM }); + onSubmitted({ at: now(), task: `t-${name}` }); + openfleet.append(fleetOf(), { event: "member.end", by: name, member: name, state: "done", summary: "its own words" }); + return { ok: true, task: `t-${name}`, outcome: "matched", state: "done", artifact: "x", error: null }; + }; + await run({}, f); + const starts = ledger().filter((l) => l.event === "member.start"); + assert.equal(starts.length, 1); + assert.equal(starts[0].by, m(1), "the session's own claim stands"); + const ends = ledger().filter((l) => l.event === "member.end"); + assert.equal(ends.length, 1); + assert.equal(ends[0].summary, "its own words"); + assert.equal(ledger().filter((l) => l.event === "swarm.end").length, 1); +})); + +test("--keep leaves the sessions running and writes no end lines", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "a" }] }); + const result = await run({ keep: true }, f); + assert.deepEqual(f.calls.kill, []); assert.equal(result.kept, true); -}); - -test("--plan-only starts nothing", async () => { - const { deps, calls } = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] }); - const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000, planOnly: true }, { deps, engineOf }); + assert.equal(result.swarm, SWARM); + assert.deepEqual(events(), ["swarm.spawn", "member.start"], "the record and the start are written; the ends are not, because nothing ended"); + assert.ok(openfleet.readMember(fleetOf(), m(1))); +})); + +test("--plan-only starts nothing and writes nothing", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] }); + const result = await run({ planOnly: true }, f); assert.equal(result.planOnly, true); assert.equal(result.plan.length, 2); - assert.equal(calls.start.length, 0); - assert.equal(calls.ai.length, 1); -}); - -test("no installed engine is a clear error", async () => { + assert.equal(result.swarm, SWARM, "the id was minted before the plan"); + assert.equal(f.calls.start.length, 0); + assert.equal(f.calls.ai.length, 1); + assert.deepEqual(events(), []); +})); + +test("a crash after the fan-out still kills the sessions it started", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] }); + f.deps.boot = async (name) => { if (name === m(2)) throw new Error("the box fell over"); return { outcome: "matched", state: "idle" }; }; + await assert.rejects(run({}, f), /fell over/); + assert.ok(f.calls.kill.includes(m(1)), "the first pane started and must not be left idle"); + assert.ok(f.calls.kill.includes(m(2))); +})); + +test("no installed engine is a clear error", () => inHome(async () => { const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { deps: fakes().deps, engineOf: () => null }); assert.equal(result.ok, false); assert.match(result.error, /install claude/); -}); +})); /* ------------------------------------------------------------- the command */ @@ -316,7 +628,7 @@ test("the command refuses an engine nobody has heard of before touching anything assert.match(lines.join("\n"), /no engine named "hal9000"/); }); -test("--json prints the run as data and narrates nothing", async () => { +test("--json prints the run as data, with the swarm and the fleet, and narrates nothing", () => inHome(async () => { const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }] }); const lines = []; const code = await swarmCommand(["T", "--json"], { write: (l) => lines.push(l), deps, engineOf }); @@ -324,10 +636,13 @@ test("--json prints the run as data and narrates nothing", async () => { assert.equal(lines.length, 1, "one JSON document, no narration"); const data = JSON.parse(lines[0]); assert.equal(data.synthesis, "THE ANSWER"); - assert.equal(data.results[0].session, "swarm-t-1"); -}); + assert.match(data.swarm, /^t-\d{4}$/); + assert.equal(data.fleet, fleetOf()); + assert.equal(data.results[0].session, `${data.swarm}-1`); + assert.equal(data.results[0].member, `${data.swarm}-1`); +})); -test("the human form ends with the answer, the ledger, and the count", async () => { +test("the human form ends with the answer, the ledger, and the count", () => inHome(async () => { const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] }); const lines = []; const code = await swarmCommand(["T"], { write: (l) => lines.push(l), deps, engineOf }); @@ -335,13 +650,32 @@ test("the human form ends with the answer, the ledger, and the count", async () const text = lines.join("\n"); assert.match(text, /THE ANSWER/); assert.match(text, /2 pieces finished/); - assert.match(text, /moshcode herd task t-swarm-t-1/); -}); + assert.match(text, /moshcode herd task t-t-\d{4}-1/); + assert.match(text, /moshcode fleet log --swarm t-\d{4}/); +})); + +test("--keep names the sessions and the swarm, so the sysop can end them later as one unit", () => inHome(async () => { + const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }] }); + const lines = []; + await swarmCommand(["T", "--keep"], { write: (l) => lines.push(l), deps, engineOf }); + assert.match(lines.join("\n"), /sessions kept \(swarm t-\d{4}\): t-\d{4}-1.*moshcode fleet stop t-\d{4}/); +})); -test("a failed piece is a non-zero exit even though the answer was written", async () => { - const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }], failStart: ["swarm-t-1"] }); +test("a failed piece is a non-zero exit even though the answer was written", () => inHome(async () => { + const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }], failStart: [] }); + deps.start = () => ({ ok: false, error: "tmux said no" }); const lines = []; const code = await swarmCommand(["T"], { write: (l) => lines.push(l), deps, engineOf }); assert.equal(code, 1); assert.match(lines.join("\n"), /0 of 1 pieces finished; 1 failed/); -}); +})); + +test("a refused swarm says why and exits 1", () => inHome(async () => { + insideMember({ member: "460a4502", depth: 0, approvals: "native" }); + const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }] }); + const lines = []; + const code = await swarmCommand(["T"], { write: (l) => lines.push(l), deps, engineOf }); + assert.equal(code, 1); + assert.match(lines.join("\n"), /refuses approvals/); + assert.match(lines.join("\n"), /0 of 1 pieces finished; 1 failed/); +})); From e4b24879bbd199bf844f4088f71946f11b57fd54 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 10:46:33 +0000 Subject: [PATCH 2/3] fix(fleet): OpenFleet review round: one ceiling order, once-markers, rule 6 in tree, honest swarm.end The review of the two OpenFleet implementations found the two sysop tools disagreeing on the same ledger. This is moshcode's half of the fix round, under the shared rulings R-A to R-J. The ceiling (R4, R5, R7, R11, R18, R1, R16, R-A, R-B, R-C): - A fleet.cap never opens a fleet; `opened` is true only for fleet.open. A cap on the implicit fleet that names no approvals leaves each root's own in place, so `moshcode fleet cap anthony@dev --depth 2` no longer stops every bypass root. - effectiveCeiling is base (latest fleet-target cap, else fleet.open, else implicit), then every swarm.spawn on the path, then the latest cap on any swarm on the path, applied last. The record's copy is a snapshot and never an input, so a later widening fleet cap takes effect. mergeCeiling never widens and carries unknown keys through. - rootOf walks records, then member.start lines, and a broken chain reads native. A parentless record supplies its own approvals. - `moshcode swarm` writes the member's own approvals into ceiling.approvals for a swarm the sysop runs by hand, so logicsrc's hook no longer refuses the pane's first prompt; each prompt's wait is clamped to an inherited until. The ledger (R28, R-G): member.start, member.end and swarm.end take a once-marker under fleets//marks/. (0600, dir 0700) before appending; EEXIST writes nothing and reports already. lost takes member.end..lost so a real end still supersedes it. Same paths as logicsrc. The verbs (R2, R8, R12, R13, R19, R20, R26, R37, R-D, R-E, R-F, R-H): - stop writes swarm.end only when every started member and every nested swarm has an end line; a member its engine would not end gets no end line, the verb exits 3 and says the swarm is left open. A never-started member writes nothing. - A claude-code member is stopped by its job id: the 8-hex member id, else the first eight of a session UUID; an interactive session has none and the tool says so. - lost is written only for a member the herd roster can hold (moshcode/*, tmux, on this host) when the manifest is readable; never for claude-code. - tree enforces rule 6: a working member past its effective until is stopped with member.end timeout, members under a swarm or fleet whose summed spend in the budget's unit has reached the budget with member.end budget, then swarm.end once the swarm is complete. - The implicit fleet is injectable, so the stop --fleet test holds on CI. Also: tree prints `working`, keeps the herd's state out of the state column (a [gone] mark instead), quotes and clips a task the way logicsrc does, and pads columns per fleet (R24); a blocked pane is not `done` (R38); herd restore hands a fresh session only OPENFLEET_HOME and OPENFLEET_FLEET (R39); the serve flag text says swarm members are withheld too (R29); no em dashes in swarm.mjs (R30, R40). PRD 0016 documents the lost rule, the markers and enforcement. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --- README.md | 12 +- ...nfleet-the-record-a-swarm-leaves-behind.md | 10 +- src/cli-schema.mjs | 20 +- src/fleet-cli.mjs | 181 ++++++++-- src/herd-cli.mjs | 21 +- src/openfleet.mjs | 313 ++++++++++++------ src/swarm.mjs | 67 ++-- test/fleet-cli.test.mjs | 202 ++++++++++- test/herd-cli.test.mjs | 16 +- test/openfleet.test.mjs | 145 +++++++- test/swarm.test.mjs | 57 +++- 11 files changed, 829 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index b854a81e..e3df8768 100644 --- a/README.md +++ b/README.md @@ -517,10 +517,10 @@ over those files, the same files `logicsrc fleet` reads: ```sh moshcode fleet tree anthony@dev (implicit fleet, sysop anthony@dev, depth 1, hosts dev) -└─ 460a4502 claude-code running [bypass] - └─ swarm create-two-0541 "create two ..." 2/4 members until 06:11 - ├─ create-two-0541-1 (172ffd83) create hello.sh bash claude-code done [bypass] owns hello.sh - └─ create-two-0541-2 create bye.sh bash moshcode/claude working [bypass] owns bye.sh +└─ 460a4502 claude-code working [bypass] + └─ swarm create-two-0541 "create two ..." 2/4 members until 06:11 + ├─ create-two-0541-1 (172ffd83) create hello.sh bash claude-code done [bypass] owns hello.sh + └─ create-two-0541-2 create bye.sh bash moshcode/claude working [bypass] owns bye.sh moshcode fleet log --swarm create-two-0541 # spawn, starts, ends, in order, who did each moshcode fleet stop create-two-0541 # end it as one unit, nested swarms first @@ -528,7 +528,9 @@ moshcode fleet open --approvals native --depth 2 # a fleet with a ceiling; new ``` `open` and `cap` are the sysop's alone: a process carrying `OPENFLEET_MEMBER` -is an agent and is refused. `moshcode ps` groups its rows by fleet and swarm +is an agent and is refused. `tree` is where the ceiling's clock and budget are +enforced: a member past its `until` or a swarm that has spent its budget is +stopped through its engine and ends `timeout` or `budget` in the ledger. `moshcode ps` groups its rows by fleet and swarm and marks every session whose approvals are bypassed. The records live under `$OPENFLEET_HOME`, default `~/.openfleet`. diff --git a/prd/0016-openfleet-the-record-a-swarm-leaves-behind.md b/prd/0016-openfleet-the-record-a-swarm-leaves-behind.md index 2a38b86c..5e355115 100644 --- a/prd/0016-openfleet-the-record-a-swarm-leaves-behind.md +++ b/prd/0016-openfleet-the-record-a-swarm-leaves-behind.md @@ -51,15 +51,15 @@ OpenFleet (logicsrc.com/docs/openfleet, 0.1) is the record and the ledger that f - R1 [P0] `moshcode swarm` mints the swarm id, `-`, before the plan call. Member ids are `-` and are also the pane names. `--json` carries `swarm` and `fleet`. - R2 [P0] The planner, the skeptic and the synthesis run without `OPENFLEET_SWARM`: the swarm's `swarm.spawn` does not exist when the planner runs, and a `claude -p` that found the id would join a swarm that has no line. - R3 [P0] The planner's reply is `[{ "title", "prompt", "files" }]`. `files`, when given, becomes the member's `piece.owns` in its record, in `swarm.spawn` and in `member.start`. -- R4 [P0] The effective ceiling is checked before anything is written: the fleet's, merged with the parent's chain, narrowed by `fan_out` from `--agents` and `until` from `--timeout`. `depth`, `fan_out`, `hosts` and `until` refuse the spawn once; `approvals` refuses each member. A refusal appends `ceiling.refuse`, starts nothing, writes no record, and reports the pieces as `outcome: "refused"`. +- R4 [P0] The effective ceiling is checked before anything is written: the fleet's whole ceiling (the latest fleet-target cap, else `fleet.open`, else the implicit fleet's), then each `swarm.spawn` narrowing on the path, then the latest cap on any swarm on the path, nothing widening and the record's own copy never an input; narrowed by `fan_out` from `--agents` and `until` from `--timeout`. In the implicit fleet approvals enter at the root, so a member of a swarm the sysop runs by hand carries its own approvals in its record's `ceiling`. When an inherited `until` is earlier than `--timeout` allows, each prompt waits at most until it. `depth`, `fan_out`, `hosts` and `until` refuse the spawn once; `approvals` refuses each member. A refusal appends `ceiling.refuse`, starts nothing, writes no record, and reports the pieces as `outcome: "refused"`. - R5 [P0] `swarm.spawn` is written before the first member starts, with the task, the narrowed keys, and one `{ member, title, owns }` per piece. `by` is the member moshcode runs inside, or `sysop` by hand. `parent_swarm` is that member's own swarm when it has one. - R6 [P0] One unclaimed record per member, written before its session starts and never rewritten: `openfleet`, `fleet`, `sysop`, `member`, `parent`, `swarm`, `task`, `piece`, `depth`, `engine` (`moshcode/`), `session` (the pane name, which is the tmux target), `host`, `cwd`, `started`, `approvals`, `ceiling`. Files are 0600, directories 0700. - R7 [P0] The pane's environment carries `OPENFLEET_HOME`, `OPENFLEET_RECORD`, `OPENFLEET_FLEET`, `OPENFLEET_MEMBER` and `OPENFLEET_SWARM` beside `MOSHCODE_HERD_NAME` and `MOSHCODE_HERD_DIR`, on the same `env` prefix, so they survive the engine's strip of `ANTHROPIC_API_KEY` and `CLAUDE_CODE_SESSION_ID`. `herd start --env KEY=VALUE` is the flag; `startSession` takes `extraEnv`. -- R8 [P0] When the prompt is submitted and the ledger holds no `member.start` for the member, moshcode writes one on the pane's behalf, `by` the starter, at the submit moment. A claude pane with hooks claims its own record first and moshcode writes nothing. +- R8 [P0] When the prompt is submitted and the ledger holds no `member.start` for the member, moshcode writes one on the pane's behalf, `by` the starter, at the submit moment. A claude pane with hooks claims its own record first and moshcode writes nothing. Before any `member.start`, `member.end` or `swarm.end` is appended the writer takes a once-marker, `fleets//marks/.` created exclusively (0600, dir 0700); on EEXIST it writes nothing and reports "already". A `lost` end takes `member.end..lost` instead, so a real end still supersedes it. logicsrc takes the same markers, so the two writers cannot both append. - R9 [P0] The manifest says `approvals: bypass` when the engine's bypass flags are present, whether from `--agent` or as plain args; `herd start --json` and its warning agree. `moshcode ps` groups rows by fleet then swarm and marks bypass members. -- R10 [P0] At the end, for every member with a record and no real end line: `member.end` with state from the herd outcome (matched is done, timeout is timeout, gone is lost, anything else failed) and `summary` from the SUMMARY: section or the tail of what it printed. Then one `swarm.end` if none exists: `done` when every member ended done, else the first of failed, stopped, budget, timeout among them; `summary` the synthesis; `verdict` the `--verify` verdicts as `[{ member, refuted, reason }]`. Then the kills, in a `finally`, so a crash still ends the panes. +- R10 [P0] At the end, for every member with a record and no real end line: `member.end` with state from the herd outcome (matched is done, unless the pane matched on `blocked`, a question rather than a finish, which is failed; timeout is timeout, gone is lost, anything else failed) and `summary` from the SUMMARY: section or the tail of what it printed. Then one `swarm.end` if none exists: `done` when every member ended done, else the first of failed, stopped, budget, timeout among them; `summary` the synthesis; `verdict` the `--verify` verdicts as `[{ member, refuted, reason }]`. Then the kills, in a `finally`, so a crash still ends the panes. - R11 [P0] `--keep` writes the records, `swarm.spawn` and `member.start`, no end lines, no kills, and names the swarm so `moshcode fleet stop ` can end it later. -- R12 [P0] `moshcode fleet open|cap|tree|stop|log` over `$OPENFLEET_HOME`, every verb with `--json`. `open` and `cap` refuse with exit 4 when `OPENFLEET_MEMBER` is set; `stop` refuses anything outside what that member spawned. `tree` joins the herd roster for liveness, draws a herd session with no record as a root of the implicit fleet, and writes `member.end` lost for a claimed moshcode member the roster no longer lists. `stop` ends nested swarms first, each with its own `swarm.end`, then the members through their engines, then the target's `swarm.end`, never two for one swarm. `cap` stops members already above the new ceiling. +- R12 [P0] `moshcode fleet open|cap|tree|stop|log` over `$OPENFLEET_HOME`, every verb with `--json`. `open` and `cap` refuse with exit 4 when `OPENFLEET_MEMBER` is set; `stop` refuses anything outside what that member spawned. `tree` joins the herd roster for liveness, draws a herd session with no record as a root of the implicit fleet, and writes `member.end` lost only for a recorded member the roster of its engine can hold and no longer lists: a `moshcode/*` or `tmux` member on this host when the herd manifest is readable. This tool reads no Claude Code roster, so it never marks a `claude-code` member lost; interactive and `-p` Claude Code sessions (UUID ids) are never marked lost by any roster. `tree` also enforces rule 6: a working member whose effective `until` has passed is stopped through its engine with `member.end` timeout, and every working member under a swarm or fleet whose summed `member.spend` in the budget's unit has reached the budget is stopped with `member.end` budget, then the swarm's `swarm.end` once it is complete. `stop` ends nested swarms first, each with its own `swarm.end`, then the members through their engines, then the target's `swarm.end`, never two for one swarm, and only once every member that started has an end line: a member whose engine would not let go gets no end line, is reported, the verb exits non-zero, and its swarm stays open. A member that never started writes nothing. A Claude Code job is stopped with `claude stop `: the member id when it is an 8-hex job id, else the first eight characters of the record's session UUID; an interactive session has no job id and the tool says so. `cap` stops members already above the new ceiling. - R13 [P1] Everything above is testable without tmux, a model, or a real home: the fleet's files live under a mkdtemp `OPENFLEET_HOME`, the roster and the kills are injectable. - R14 [P1] `moshcode help fleet`, the README, the pit's `/fleet` and moshscript's `fleet(...)` document the verb; the README table is regenerated from the schema. @@ -78,7 +78,7 @@ OpenFleet (logicsrc.com/docs/openfleet, 0.1) is the record and the ledger that f ## Risks & Open Questions -- The `member.start` a starter writes at submit time can race a claude pane's own hook claiming the record; both check the ledger first, and a reader takes the first line, so a duplicate is noise rather than a wrong tree. +- The `member.start` a starter writes at submit time races a claude pane's own hook claiming the record. Both check the ledger first, and both take the once-marker under `marks/` before appending, so only one line lands; a ledger written by an older writer without markers still reads right, because a reader takes the first line. - The pane's `session` equals its member id, so a claude session that claims the record keeps the tmux target as its handle and its own session id appears nowhere; the spec allows this and the tmux target is what a sysop tool stops. - `until` is one `--timeout` from the spawn; every piece is bounded by that wait and the plan is capped at `--agents`, so one batch is the whole swarm. A later `--agents` smaller than the plan would need the batch arithmetic the code already carries. - A `moshcode swarm` run inside a member at depth 1 of the implicit fleet is refused on depth. Correct under the spec, and noisy for a tool that spawns many; `moshcode fleet open --depth 2` is the answer. diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index a1845377..7e6657c1 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -160,8 +160,9 @@ export const CORE_CLI_COMMANDS = [ note: "the records and the ledger live under $OPENFLEET_HOME (default ~/.openfleet), the same files `logicsrc fleet` and claude code read. " + "open and cap are the sysop's alone and refuse when OPENFLEET_MEMBER is set, because a process carrying it is an agent; " + "stop refuses anything outside what that agent spawned. tree joins the herd roster for liveness, draws a herd session with no " - + "record as a root of the implicit fleet @, and marks a recorded member the roster no longer lists as lost. " - + "moshcode stops its own panes; a claude code member goes through `claude stop`, a `claude -p` through its pid.", + + "record as a root of the implicit fleet @, marks a recorded pane the roster no longer lists as lost, and stops " + + "whatever has passed its until or spent its budget. moshcode stops its own panes; a claude code job goes through `claude stop`, " + + "a `claude -p` through its pid.", }, { name: "ps", @@ -1422,7 +1423,7 @@ export const HERD_VERBS = [ flags: [ ["--port ", "port to listen on", "7683"], ["--bind ", "interface to bind", "127.0.0.1"], - ["--expose-autonomous", "also serve sessions started with --agent", "off"], + ["--expose-autonomous", "also serve sessions whose approvals are bypassed: --agent, or a swarm's members", "off"], ], note: "message/send is keystrokes into a real pty. there is no unauthenticated mode, loopback included, and sessions " + "started with --agent are withheld unless you ask for them: an engine with approvals bypassed plus a network prompt " @@ -1476,16 +1477,21 @@ export const FLEET_VERBS = [ flags: [["--json", "the folded model: fleets, nodes, refusals, spend", ""]], note: "fleet, its root members, each member's swarms, each swarm's members: state, engine, depth, spend against budget, owns, and a " + "[bypass] mark on every member whose approvals are bypassed. a herd session with no record is a root of the implicit fleet " - + "marked [roster]; a recorded moshcode member the roster no longer lists gets member.end lost." }, + + "marked [roster]; a recorded moshcode pane the herd roster can hold and no longer lists gets member.end lost (a claude code " + + "session is never marked lost here: this tool reads no claude roster). tree also enforces the ceiling's clock and budget: a " + + "working member past its until is stopped with member.end timeout, one under a swarm or fleet that has spent its budget with " + + "member.end budget, then the swarm's swarm.end once every member has ended." }, { name: "stop", description: "end a member, a swarm, or a whole fleet as one unit", synopsis: [ ["moshcode fleet stop ", "nested swarms first, each with its own swarm.end, then the members, then the target's swarm.end"], ["moshcode fleet stop --fleet ", "everything in the fleet"], ], flags: [["--fleet ", "stop every swarm and root member of this fleet", ""], ["--json", "machine-readable", ""]], - note: "each member ends through its own engine: a moshcode pane through the herd, a claude code job through `claude stop`, a `claude -p` " - + "through its pid; one it cannot reach is reported, never faked. an agent (OPENFLEET_MEMBER set) may stop only a swarm it spawned " - + "or a member under one; --fleet, an ancestor or a sibling's swarm is refused with exit 4. a member already ended writes nothing." }, + note: "each member ends through its own engine: a moshcode pane through the herd, a claude code job through `claude stop ` " + + "(the 8-hex id; an interactive claude session has none and is reported), a `claude -p` through its pid; one it cannot reach is " + + "reported, never faked, and its swarm stays open until it has an end line. an agent (OPENFLEET_MEMBER set) may stop only a swarm " + + "it spawned or a member under one; --fleet, an ancestor or a sibling's swarm is refused with exit 4. a member already ended, or " + + "never started, writes nothing." }, { name: "log", description: "the ledger for a fleet, a swarm or a member, in order", synopsis: [["moshcode fleet log [fleet] [--since 1h] [--member ] [--swarm ] [--json]", ""]], flags: [ diff --git a/src/fleet-cli.mjs b/src/fleet-cli.mjs index c2a8b226..4056ed91 100644 --- a/src/fleet-cli.mjs +++ b/src/fleet-cli.mjs @@ -11,9 +11,11 @@ // goes through herdKill; a Claude Code job through `claude stop`; a `claude -p` // through its pid. Anything else is reported, never faked. import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; import { EXIT, herdKill, roster as herdRoster } from "./herd-cli.mjs"; -import { slugifyName } from "./herd.mjs"; +import { herdDir, slugifyName } from "./herd.mjs"; import * as fleet from "./openfleet.mjs"; import { acid, amber, ash, bone, dim, err, info, ok, warn } from "./ui.mjs"; @@ -118,7 +120,14 @@ function agentRefusal(env, verb) { /* --------------------------------------------------------------- the world */ +/** + * The herd's roster, or null when its manifest cannot be read. Null matters: + * a member is marked lost only by a roster that could hold it and does not, + * and a manifest that is missing or torn says nothing about any pane. + */ function liveRoster() { + try { JSON.parse(fs.readFileSync(path.join(herdDir(), "sessions.json"), "utf8")); } + catch { return null; } return herdRoster().map((s) => ({ name: s.name, engine: `moshcode/${s.engine}`, state: s.state, alive: s.alive, approvals: s.approvals || "native", cwd: s.cwd, fleet: s.fleet || null, swarm: s.swarm || null, member: s.member || null, @@ -175,52 +184,138 @@ function locate(id, model, env) { /* ---------------------------------------------------------------- stopping */ +const HEX8 = /^[0-9a-f]{8}$/i; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * The job id `claude stop` takes for a claude-code member: the member id when + * it is a background job's 8-hex id, else the first eight characters of the + * record's session when that is a session UUID (a job's id is its session + * id's first eight). An interactive session is its own UUID with no job id, + * and nothing this tool runs can end it. + */ +export function claudeJobId(m) { + if (HEX8.test(String(m.member || ""))) return m.member; + const session = String(m.session || ""); + if (HEX8.test(session)) return session; + if (UUID.test(session) && session !== m.member) return session.slice(0, 8); + return null; +} + /** - * End one member through its own engine, and write its end line. Returns - * what happened; never throws. A member the engine no longer has, with no end - * line from anyone, is `lost`, so the ledger closes rather than hangs. + * End one member through its own engine, and write its end line under its + * once-marker. Returns what happened; never throws. A member that never + * started gets nothing. A member its engine's roster could hold and no + * longer lists, with no end line from anyone, is `lost`, so the ledger closes + * rather than hangs; one the engine still has but could not end is reported + * and left open, because an end line the engine did not honour is a lie. */ async function stopMember(m, fleetId, state, o) { const { env, now, host } = o; const by = env.OPENFLEET_MEMBER || "sysop"; if (m.end && m.end.state !== "lost") return { member: m.member, outcome: "already-ended", state: m.end.state }; + if (!m.claimed && !m.rosterOnly) return { member: m.member, outcome: "never-started" }; const session = m.session || m.member; let ended; - if (m.rosterOnly || /^moshcode\//.test(m.engine || "") || m.engine === "tmux") ended = await o.kill(session); - else if (m.engine === "claude-code") ended = o.exec("claude", ["stop", session]); - else if (m.engine === "claude-p") ended = Number.isInteger(Number(session)) ? o.signal(Number(session)) : { ok: false, error: "no pid in the record" }; + if (m.rosterOnly || fleet.herdHolds(m.engine)) ended = await o.kill(session); + else if (m.engine === "claude-code") { + const job = claudeJobId(m); + ended = job ? o.exec("claude", ["stop", job]) : { ok: false, error: "an interactive claude-code session has no job id: end it from its own terminal" }; + } else if (m.engine === "claude-p") ended = Number.isInteger(Number(session)) ? o.signal(Number(session)) : { ok: false, error: "no pid in the record" }; else ended = { ok: false, error: `no engine to stop it through (${m.engine || "unknown"})` }; if (m.rosterOnly) return { member: m.member, outcome: ended.ok ? "stopped" : "failed", error: ended.error || null }; if (ended.ok) { - fleet.append(fleetId, { event: "member.end", by, member: m.member, state }, { env, now: now(), host }); + const { already } = fleet.appendOnce(fleetId, { event: "member.end", by, member: m.member, state }, { env, now: now(), host }); + if (already) return { member: m.member, outcome: "already-ended", state: fleet.endOf(fleet.readLedger(fleetId, env), m.member)?.state || state }; return { member: m.member, outcome: "stopped", state }; } - const listed = m.engine && /^moshcode\//.test(m.engine) ? o.roster().some((r) => r.name === session) : true; - if (!listed && !m.end) { - fleet.append(fleetId, { event: "member.end", by, member: m.member, state: "lost" }, { env, now: now(), host }); + if (m.end) return { member: m.member, outcome: "already-ended", state: m.end.state }; + const roster = fleet.herdHolds(m.engine) && m.host === host ? o.roster() : null; + if (roster && !roster.some((r) => r.name === session)) { + fleet.appendOnce(fleetId, { event: "member.end", by, member: m.member, state: "lost" }, { env, now: now(), host }); return { member: m.member, outcome: "lost", state: "lost" }; } return { member: m.member, outcome: "failed", error: ended.error || "could not stop it" }; } +/** + * Write a swarm's end line, once, and only when every member that started + * has an end line that counts and every nested swarm has ended: a swarm.end + * says the whole unit is over, so a member whose engine would not let go + * leaves the swarm open and reported rather than closed over a running + * session. `state` is the end the caller is writing, used when no member + * ever started; otherwise the members' end lines decide. + */ +function closeSwarm(s, fleetId, state, o, out) { + const { env, now, host } = o; + const by = env.OPENFLEET_MEMBER || "sysop"; + const lines = fleet.readLedger(fleetId, env); + if (fleet.findEvents(lines, "swarm.end", { swarm: s.swarm }).length) { out.swarms.push({ swarm: s.swarm, state: "already-ended" }); return; } + const started = s.members.filter((m) => m.claimed && !m.rosterOnly); + const missing = started.filter((m) => !fleet.endOf(lines, m.member)).map((m) => m.member); + const open = s.members.flatMap((m) => m.swarms).filter((n) => !fleet.findEvents(lines, "swarm.end", { swarm: n.swarm }).length).map((n) => n.swarm); + if (missing.length || open.length) { out.swarms.push({ swarm: s.swarm, state: "open", missing, nested: open }); return; } + const ends = started.map((m) => fleet.endOf(lines, m.member)); + const { line, already } = fleet.appendOnce(fleetId, { event: "swarm.end", by, swarm: s.swarm, state: started.length ? fleet.swarmEndState(ends) : state }, { env, now: now(), host }); + out.swarms.push({ swarm: s.swarm, state: already ? "already-ended" : line?.state || null }); +} + /** * End a swarm as one unit (rule 11): nested swarms first, each with its own * swarm.end, then the members through their engines, then this swarm's end - * line, written only when none exists. + * line, written only when none exists and every member has ended. */ async function stopSwarm(s, fleetId, state, o, out) { - const { env, now, host } = o; - const by = env.OPENFLEET_MEMBER || "sysop"; for (const m of s.members) for (const nested of m.swarms) await stopSwarm(nested, fleetId, state, o, out); for (const m of s.members) out.members.push(await stopMember(m, fleetId, state, o)); - if (!fleet.hasEvent(fleetId, "swarm.end", { swarm: s.swarm }, env)) { - const lines = fleet.readLedger(fleetId, env); - const ends = s.members.map((m) => fleet.endOf(lines, m.member)).filter(Boolean); - const line = fleet.append(fleetId, { event: "swarm.end", by, swarm: s.swarm, state: fleet.swarmEndState(ends) }, { env, now: now(), host }); - out.swarms.push({ swarm: s.swarm, state: line?.state || null }); - } else { - out.swarms.push({ swarm: s.swarm, state: "already-ended" }); + closeSwarm(s, fleetId, state, o, out); +} + +/** + * Rule 6, run by `tree` since the sysop runs it: a working member whose + * effective ceiling's `until` has passed is stopped through its engine with + * `member.end` timeout, and every working member under a swarm or fleet whose + * summed `member.spend`, in the budget's unit, has reached the budget is + * stopped with `member.end` budget. A swarm a stop touched gets its swarm.end + * once it is complete, deepest first. Returns what happened. + */ +async function enforceCeilings(model, o) { + const { env, now, host } = o; + const out = { members: [], swarms: [] }; + const working = (m) => m.claimed && !m.end && !m.rosterOnly && !m.lost; + const overBudget = (budget, members) => { + const cap = fleet.parseBudget(budget); + if (!cap) return false; + const spent = members.map((m) => fleet.parseBudget(m.spend)).filter((b) => b && b.unit === cap.unit).reduce((sum, b) => sum + b.amount, 0); + return spent >= cap.amount; + }; + for (const f of model.fleets) { + const lines = fleet.readLedger(f.fleet, env); + const touched = new Set(); + const stop = async (m, state, over) => { + const result = await stopMember(m, f.fleet, state, o); + if (result.outcome === "stopped" || result.outcome === "lost") m.end = { state: result.state }; + out.members.push({ ...result, over }); + for (const s of walkSwarms(f.nodes)) if (s.members.includes(m)) touched.add(s); + }; + // Deepest members first, as a stop on a swarm ends nested swarms first. + for (const m of [...walkMembers(f.nodes)].reverse()) { + if (!working(m)) continue; + const effective = fleet.ceilingOf(f.fleet, fleet.readMember(f.fleet, m.member, env), { env, lines, swarm: m.swarm, host }); + const deadline = Date.parse(effective.until || ""); + if (Number.isFinite(deadline) && now() >= deadline) await stop(m, "timeout", "until"); + } + for (const s of walkSwarms(f.nodes)) { + const members = [...walkMembers(s.members)].reverse(); + if (!overBudget(fleet.effectiveCeiling(lines, f.fleet, { swarm: s.swarm, host }).budget, members)) continue; + for (const m of members) if (working(m)) await stop(m, "budget", "budget"); + } + const everyone = [...walkMembers(f.nodes)].reverse(); + if (overBudget(f.ceiling?.budget, everyone)) for (const m of everyone) if (working(m)) await stop(m, "budget", "budget"); + // Deepest first, so a parent sees its nested swarm's end line. + for (const s of [...walkSwarms(f.nodes)].reverse()) if (touched.has(s)) closeSwarm(s, f.fleet, "stopped", o, out); } + return out; } /** May an agent stop this target? Only a swarm it spawned, or anything under one. */ @@ -245,14 +340,17 @@ function withinReach(caller, target, model) { function reportStops(out, { write, json }) { if (json) { write(JSON.stringify(out, null, 2)); return; } + const why = (m) => (m.over === "until" ? " (past its until)" : m.over === "budget" ? " (over budget)" : m.over ? ` (above the ceiling on ${m.over})` : ""); for (const m of out.members) { - if (m.outcome === "stopped") write(ok(`${bone(m.member)} stopped`)); + if (m.outcome === "stopped") write(ok(`${bone(m.member)} stopped${m.state && m.state !== "stopped" ? ` as ${m.state}` : ""}${why(m)}`)); else if (m.outcome === "lost") write(warn(`${m.member} was already gone: marked lost`)); else if (m.outcome === "already-ended") write(dim(`${m.member} had already ended (${m.state})`)); + else if (m.outcome === "never-started") write(dim(`${m.member} never started: nothing to end`)); else write(err(`${m.member}: ${m.error}`)); } for (const s of out.swarms) { if (s.state === "already-ended") write(dim(`swarm ${s.swarm} had already ended`)); + else if (s.state === "open") write(warn(`swarm ${s.swarm} left open: no end line yet for ${[...(s.missing || []), ...(s.nested || []).map((n) => `swarm ${n}`)].join(", ")}`)); else write(ok(`swarm ${bone(s.swarm)} ended ${s.state}`)); } } @@ -274,7 +372,7 @@ async function fleetOpen(argv, o) { const name = slugifyName(flags.name || positional[0] || "fleet"); let id = `${name}-${stamp}`; for (let n = 2; fleet.listFleets(env).includes(id); n++) id = `${name}-${stamp}-${n}`; - const sysop = flags.sysop || fleet.implicitFleet(env); + const sysop = flags.sysop || o.implicit; const line = fleet.append(id, { event: "fleet.open", by: "sysop", sysop, ceiling }, { env, now: now(), host }); if (!line) { write(err(`could not write ${fleet.ledgerPath(id, env)}`)); return EXIT.infra; } fleet.writeCurrent(id, env); @@ -294,7 +392,7 @@ async function fleetCap(argv, o) { const target = positional[0]; if (errors.length || bad.length || !target) { write(err(USAGE.cap)); return EXIT.usage; } - const model = fleet.fold({ fleets: loadFleets(env, flags.fleet || null), roster: o.roster(), host, implicit: fleet.implicitFleet(env), now: now() }); + const model = fleet.fold({ fleets: loadFleets(env, flags.fleet || null), roster: o.roster(), host, implicit: o.implicit, now: now() }); const found = locate(target, model, env); if (!found || found.kind === "member") { write(err(`no fleet or swarm named ${JSON.stringify(target)}: ${acid("moshcode fleet tree")}`)); return EXIT.gone; } const fleetId = found.fleet; @@ -312,7 +410,7 @@ async function fleetCap(argv, o) { // a bypass member under a now-native ceiling, a member on a now-forbidden // host, one deeper than the tree now allows. const lines = fleet.readLedger(fleetId, env); - const fresh = fleet.fold({ fleets: [{ fleet: fleetId, lines, records: fleet.listRecords(fleetId, env) }], roster: o.roster(), host, implicit: fleet.implicitFleet(env), now: now() }); + const fresh = fleet.fold({ fleets: [{ fleet: fleetId, lines, records: fleet.listRecords(fleetId, env) }], roster: o.roster(), host, implicit: o.implicit, now: now() }); const scope = found.kind === "fleet" ? fresh.fleets[0]?.nodes || [] : [[...walkSwarms(fresh.fleets[0]?.nodes || [])].find((s) => s.swarm === target)].filter(Boolean); const out = { target, kind: found.kind, fleet: fleetId, ceiling, members: [], swarms: [] }; for (const m of walkMembers(scope)) { @@ -335,27 +433,43 @@ async function fleetTree(argv, o) { for (const e of errors) write(err(e)); if (errors.length) { write(err(USAGE.tree)); return EXIT.usage; } const only = flags.fleet || positional[0] || null; - const implicit = fleet.implicitFleet(env); + const implicit = o.implicit; if (only && only !== implicit && !fleet.listFleets(env).includes(only)) { write(err(`no fleet named ${JSON.stringify(only)} under ${fleet.home(env)}`)); return EXIT.gone; } const model = fleet.fold({ fleets: loadFleets(env, only), roster: o.roster(), host, implicit, now: now() }); if (only) model.fleets = model.fleets.filter((f) => f.fleet === only); // A recorded moshcode member the roster no longer lists, with no end line - // from anyone, is lost: the tool writes that line so the tree stops saying - // "running" about a pane that is not there (spec, `tree`). + // from anyone, is lost: the tool writes that line, under the lost marker a + // real end may still supersede, so the tree stops saying "working" about a + // pane that is not there (spec, `tree`). const by = env.OPENFLEET_MEMBER || "sysop"; for (const f of model.fleets) { for (const m of walkMembers(f.nodes)) { if (!m.lost) continue; - const line = fleet.append(f.fleet, { event: "member.end", by, member: m.member, state: "lost" }, { env, now: now(), host }); + const { line } = fleet.appendOnce(f.fleet, { event: "member.end", by, member: m.member, state: "lost" }, { env, now: now(), host }); if (line) m.end = line; } } + // Rule 6: the sysop runs tree, so tree is where the clock and the budget + // are enforced. What it stopped is reported after the tree. + const enforced = await enforceCeilings(model, o); + for (const f of model.fleets) { + for (const m of walkMembers(f.nodes)) { + const hit = enforced.members.find((r) => r.member === m.member && (r.outcome === "stopped" || r.outcome === "lost")); + if (hit) { m.state = hit.state; m.lost = hit.state === "lost"; } + } + for (const s of walkSwarms(f.nodes)) { + const hit = enforced.swarms.find((r) => r.swarm === s.swarm && r.state && r.state !== "open" && r.state !== "already-ended"); + if (hit) s.state = hit.state; + } + } + if (enforced.members.length) model.enforced = enforced; if (flags.json) { write(JSON.stringify(model, null, 2)); return EXIT.matched; } if (!model.fleets.length) { write(info(`no fleet yet under ${fleet.home(env)}: ${acid("moshcode fleet open")} opens one, ${acid("moshcode swarm")} records its members in the implicit fleet ${bone(implicit)}.`)); return EXIT.matched; } for (const line of fleet.renderTree(model, { host }).split("\n")) write(line); + if (enforced.members.length) reportStops(enforced, { write, json: false }); return EXIT.matched; } @@ -366,7 +480,7 @@ async function fleetStop(argv, o) { const target = positional[0] || null; if (errors.length || (!target && !flags.fleet)) { write(err(USAGE.stop)); return EXIT.usage; } const caller = env.OPENFLEET_MEMBER || null; - const model = fleet.fold({ fleets: loadFleets(env), roster: o.roster(), host: o.host, implicit: fleet.implicitFleet(env), now: o.now() }); + const model = fleet.fold({ fleets: loadFleets(env), roster: o.roster(), host: o.host, implicit: o.implicit, now: o.now() }); const out = { target: target || flags.fleet, members: [], swarms: [] }; if (flags.fleet && !target) { @@ -396,7 +510,9 @@ async function fleetStop(argv, o) { out.members.push(await stopMember(found.node, found.fleet, "stopped", o)); } reportStops(out, { write, json: Boolean(flags.json) }); - return out.members.some((m) => m.outcome === "failed") ? EXIT.gone : EXIT.matched; + // The one member asked for was never there to stop: not found, as logicsrc says. + const nothing = found.kind === "member" && out.members.at(-1)?.outcome === "never-started"; + return out.members.some((m) => m.outcome === "failed") || nothing ? EXIT.gone : EXIT.matched; } function describeLine(l) { @@ -471,6 +587,9 @@ export async function fleetCommand(argv = [], { write = console.log, ...deps } = roster: liveRoster, kill: liveKill, exec: liveExec, signal: liveSignal, ...deps, }; + // The implicit fleet is injectable so a test's seeded `@` is + // the one the roster-only rows file under, on any box. + o.implicit = deps.implicit || fleet.implicitFleet(o.env); const [verb, ...rest] = argv; if (!verb || verb.startsWith("--")) return fleetTree(argv, o); const run = VERBS[verb]; diff --git a/src/herd-cli.mjs b/src/herd-cli.mjs index 5b16f91a..fcf4b23a 100644 --- a/src/herd-cli.mjs +++ b/src/herd-cli.mjs @@ -1076,6 +1076,23 @@ async function deliver(session, config, write) { write(sent.ok ? ok(`answered ${session.name}: ${reply}`) : warn(`could not type the reply into ${session.name}`)); } +/** + * The environment a restored session gets back from the manifest. A resumed + * session is the same conversation and re-exports as the same OpenFleet + * member, so it keeps everything. A fresh session under a record that a dead + * session already claimed would have to derive a child (rule 8) and, at + * depth 1 in the implicit fleet, be refused on its first prompt; it keeps the + * home and the fleet and drops the record, member and swarm. Exported for its + * test. + */ +export function restoreEnv(env = {}, { resumed = false } = {}) { + const all = env || {}; + if (resumed) return { env: { ...all }, dropped: [] }; + const claimed = ["OPENFLEET_RECORD", "OPENFLEET_MEMBER", "OPENFLEET_SWARM"]; + const dropped = claimed.filter((k) => k in all); + return { env: Object.fromEntries(Object.entries(all).filter(([k]) => !claimed.includes(k))), dropped }; +} + /** * Rebuild the herd from the manifest. * @@ -1111,10 +1128,12 @@ export function herdRestore(argv, { write = console.log } = {}) { if (dryRun) { write(info(`would restore ${bone(name)} — ${meta.engine} in ${tilde(meta.cwd)}${resumeArgs ? " (resumed)" : ""}`)); restored++; continue; } const bin = resolveExecutable(engine.bin, engine.binDirs || []) || engine.bin; - const started = startSession({ name, engine: meta.engine, bin, args, stripEnv: engine.stripEnv || [], cwd: meta.cwd, substrate, extraEnv: meta.env || {} }); + const { env: extraEnv, dropped } = restoreEnv(meta.env, { resumed: Boolean(resumeArgs) }); + const started = startSession({ name, engine: meta.engine, bin, args, stripEnv: engine.stripEnv || [], cwd: meta.cwd, substrate, extraEnv }); if (!started.ok) { write(err(`${name}: ${started.error?.message || started.error}`)); continue; } clearReport(name); write(ok(`${bone(name)} — ${meta.engine} in ${tilde(meta.cwd)}${resumeArgs ? ash(" (asked to resume)") : ""}`)); + if (dropped.length) write(info(`${name}: a fresh session is a new member, not ${meta.member || "the old one"}: ${dropped.join(", ")} not handed back (--resume keeps them)`)); restored++; } if (restored && !dryRun) { diff --git a/src/openfleet.mjs b/src/openfleet.mjs index 2abdce2c..867584a3 100644 --- a/src/openfleet.mjs +++ b/src/openfleet.mjs @@ -164,21 +164,70 @@ export function ledgerPaths(fleet, env = process.env) { } /** - * Append one line to this host's ledger. Adds `at`, `fleet` and `host`; the - * caller passes `event`, `by` and the event's own keys. Never throws: a lost - * ledger line must not fail the swarm that was writing it. Returns the line as - * written, or null. + * The once-marker a line takes before it is appended, so two writers that + * both checked the ledger and found nothing cannot both append: a claude + * pane's hook and the moshcode that started it race on `member.start`, and + * `member.end` and `swarm.end` have the same check-then-append shape. The + * marker is `fleets//marks/.`, created exclusively; on + * EEXIST the line is not written and the caller hears "already". A `lost` + * end takes `member.end..lost` instead, so the engine's or the spawner's + * real end can still supersede it and takes the plain marker. logicsrc + * writes the same paths. Null for an event that has no marker. */ -export function append(fleet, line, { env = process.env, now = Date.now(), host: h = host() } = {}) { +export function markerFor(line) { + const { event, member, swarm, state } = line || {}; + if (event === "member.start" && member) return `member.start.${member}`; + if (event === "member.end" && member) return state === "lost" ? `member.end.${member}.lost` : `member.end.${member}`; + if (event === "swarm.end" && swarm) return `swarm.end.${swarm}`; + return null; +} + +export function markPath(fleet, marker, env = process.env) { + return path.join(fleetDir(fleet, env), "marks", marker); +} + +/** + * Take a once-marker. True when this call created it, false when another + * writer already had. A marker that cannot be created for any other reason + * (an unwritable home) counts as taken: the append that follows will say + * whether the ledger is writable at all. + */ +export function claimOnce(fleet, marker, env = process.env) { + const file = markPath(fleet, marker, env); + try { + ensureDir(path.dirname(file)); + fs.writeFileSync(file, "", { flag: "wx", mode: 0o600 }); + fs.chmodSync(file, 0o600); + return true; + } catch (error) { + return error?.code !== "EEXIST"; + } +} + +/** + * Append one line to this host's ledger, taking its once-marker first when + * the event has one. Adds `at`, `fleet` and `host`; the caller passes + * `event`, `by` and the event's own keys. Never throws: a lost ledger line + * must not fail the swarm that was writing it. Returns `{ line, already }`: + * the line as written, or null, and whether another writer got there first. + */ +export function appendOnce(fleet, line, { env = process.env, now = Date.now(), host: h = host(), once = true } = {}) { const { at, event, by, ...rest } = line || {}; const written = { at: at || iso(now), event, fleet, host: h, by, ...rest }; + const marker = once ? markerFor(written) : null; + if (marker && !claimOnce(fleet, marker, env)) return { line: null, already: true }; try { ensureDir(fleetDir(fleet, env)); const file = ledgerPath(fleet, env); fs.appendFileSync(file, `${JSON.stringify(written)}\n`, { mode: 0o600 }); fs.chmodSync(file, 0o600); - return written; - } catch { return null; } + return { line: written, already: false }; + } catch { return { line: null, already: false }; } +} + +/** appendOnce for a caller that only needs the line: the line as written, or null. */ +export function append(fleet, line, options = {}) { + return appendOnce(fleet, line, options).line; } const atMs = (line) => { @@ -250,20 +299,29 @@ export function parseBudget(value) { return m ? { amount: Number(m[1]), unit: m[2] } : null; } -/** Key by key: every key `narrowing` carries replaces the one in `base`. */ +/** An ISO time as epoch milliseconds, or null when it is not one. */ +export const untilMs = (value) => { + const t = Date.parse(String(value ?? "")); + return Number.isFinite(t) ? t : null; +}; + +/** + * Key by key: a key `narrowing` carries replaces the one in `base` only when + * it is narrower or equal. A merged ceiling never widens, whatever a ledger + * line claims (rule 3): a spawn line saying `approvals: bypass` under a + * native fleet is ignored here and refused by the engine that checks it. + * Unknown keys ride through unchanged, as the spec keeps them. + */ export function mergeCeiling(base = {}, narrowing = {}) { const out = { ...(base || {}) }; - for (const key of CEILING_KEYS) { - if (narrowing && narrowing[key] !== undefined && narrowing[key] !== null) out[key] = narrowing[key]; + for (const [key, value] of Object.entries(narrowing || {})) { + if (value === undefined || value === null) continue; + if (!CEILING_KEYS.includes(key)) { out[key] = value; continue; } + if (keyNarrower(key, value, out)) out[key] = value; } return out; } -const untilMs = (value) => { - const t = Date.parse(String(value ?? "")); - return Number.isFinite(t) ? t : null; -}; - /** * Is one key of `narrowing` narrower than, or equal to, the same key of `base`? * Absent in base means uncapped for budget, fan_out, hosts and until; native @@ -361,22 +419,24 @@ export function checkCeiling(wanted = {}, allowed = {}, { now = Date.now() } = { /** * A fleet's whole ceiling: the latest `fleet.cap` whose target is the fleet, - * else `fleet.open`, else the implicit fleet's. An opened fleet's absent keys - * are resolved the way the spec reads them (approvals native, depth 1, hosts - * the host the line was written on); the implicit fleet carries no approvals - * key at all, because each root member supplies its own. + * else `fleet.open`, else the implicit fleet's. Absent `depth` is 1 and + * absent `hosts` is the host the line was written on. `opened` is true only + * when a `fleet.open` line exists: a cap never opens a fleet. In an opened + * fleet an absent `approvals` means native, whether the ceiling comes from + * `fleet.open` or a later cap; a cap on the implicit fleet that names no + * approvals leaves each root's own approvals in place, so the key stays + * absent here and enters at the root in effectiveCeiling. */ export function fleetCeiling(lines, fleet, { host: h = host() } = {}) { const caps = findEvents(lines, "fleet.cap", { target: fleet }); - const open = findEvents(lines, "fleet.open"); - const line = caps.at(-1) || open.at(-1) || null; + const open = findEvents(lines, "fleet.open").at(-1) || null; + const opened = Boolean(open); + const line = caps.at(-1) || open; if (!line) return { ceiling: { depth: 1, hosts: [h] }, opened: false, line: null }; const c = line.ceiling || {}; - return { - ceiling: { ...c, approvals: c.approvals || "native", depth: c.depth ?? 1, hosts: Array.isArray(c.hosts) ? c.hosts : [line.host || h] }, - opened: true, - line, - }; + const ceiling = { ...c, depth: c.depth ?? 1, hosts: Array.isArray(c.hosts) ? c.hosts : [line.host || h] }; + if (opened && !ceiling.approvals) ceiling.approvals = "native"; + return { ceiling, opened, line }; } /** The `swarm.spawn` lines from the top-most ancestor down to `swarm`. */ @@ -394,35 +454,52 @@ export function swarmChain(lines, swarm) { return chain; } -/** The record at the top of a member's parent chain, or the deepest one readable. */ -export function rootOf(fleet, record, env = process.env) { +/** + * The root above a record: `parent` followed through record files, then + * through `member.start` lines when a record is missing (it may have been + * written on another host). Null when the chain breaks, which reads as + * approvals native: nothing vouches for more. + */ +export function rootOf(fleet, record, { env = process.env, lines = readLedger(fleet, env) } = {}) { let current = record; - const seen = new Set(); - while (current?.parent && !seen.has(current.parent)) { - seen.add(current.parent); - const parent = readMember(fleet, current.parent, env); - if (!parent) break; - current = parent; + const seen = new Set([record?.member]); + while (current?.parent) { + const parent = current.parent; + if (seen.has(parent)) return null; + seen.add(parent); + const file = readMember(fleet, parent, env); + if (file) { current = file; continue; } + const start = claimedBy(lines, parent); + if (!start) return null; + current = { member: parent, approvals: start.approvals, parent: start.parent }; } return current || null; } +/** The approvals a root supplies to its subtree in the implicit fleet: its own, or native when orphan. */ +export function rootApprovalsOf(root) { + if (!root || root.orphan) return "native"; + return root.approvals === "bypass" ? "bypass" : "native"; +} + /** - * The effective ceiling at a point in the tree: the fleet's, merged key by key - * with each `swarm.spawn` on the path down to `swarm`, with the latest - * `fleet.cap` for any swarm on that path applied after its spawn. In the - * implicit fleet `approvals` enters at the root: `rootApprovals`, or the - * record's own ceiling when the caller has one and no chain to rebuild from. + * The effective ceiling at a point in the tree, in the order both sysop + * tools use: the fleet's whole ceiling as the base (the latest fleet-target + * cap, else `fleet.open`, else the implicit fleet's), then each `swarm.spawn` + * narrowing on the path from the root swarm down to `swarm`, then the latest + * `fleet.cap` for any swarm on that path, applied last so the sysop's word + * wins over what a spawner wrote. Nothing in the merge widens. The copy in a + * member's record is a snapshot and never an input here, so a later fleet + * cap takes effect (rule 7). In the implicit fleet `approvals` enters at the + * root, `rootApprovals`, unless a cap on the fleet named the key itself. */ -export function effectiveCeiling(lines, fleet, { swarm = null, rootApprovals = null, record = null, host: h = host() } = {}) { +export function effectiveCeiling(lines, fleet, { swarm = null, rootApprovals = null, host: h = host() } = {}) { const { ceiling: base, opened } = fleetCeiling(lines, fleet, { host: h }); let ceiling = { ...base }; - if (!opened) { - if (record?.ceiling) ceiling = { ...record.ceiling }; - if (rootApprovals) ceiling.approvals = rootApprovals; - } - for (const spawn of swarmChain(lines, swarm)) { - ceiling = mergeCeiling(ceiling, spawn.ceiling || {}); + if (!opened && ceiling.approvals === undefined && rootApprovals) ceiling.approvals = rootApprovals; + const chain = swarmChain(lines, swarm); + for (const spawn of chain) ceiling = mergeCeiling(ceiling, spawn.ceiling || {}); + for (const spawn of chain) { const cap = findEvents(lines, "fleet.cap", { target: spawn.swarm }).at(-1); if (cap) ceiling = mergeCeiling(ceiling, cap.ceiling || {}); } @@ -431,21 +508,15 @@ export function effectiveCeiling(lines, fleet, { swarm = null, rootApprovals = n /** * The effective ceiling a member's record was, or would be, started under: - * effectiveCeiling with the implicit fleet's root approvals resolved. In the - * implicit fleet approvals enter at the root: when the walk up the parent - * chain reaches it, its own flags decide; when a record on the way up is - * missing, the ceiling copied into this record is the best remaining witness. + * effectiveCeiling with the implicit fleet's root approvals resolved by + * walking the parent chain to its root. A parentless record is its own root + * and supplies its own approvals (never native by default); a broken chain + * vouches for nothing and reads native. */ export function ceilingOf(fleet, record, { env = process.env, lines = readLedger(fleet, env), swarm = record?.swarm || null, host: h = host() } = {}) { const { opened } = fleetCeiling(lines, fleet, { host: h }); - let rootApprovals = null; - if (record && !opened) { - const root = rootOf(fleet, record, env); - rootApprovals = root && !root.parent - ? (root.orphan ? "native" : root.approvals || "native") - : record.ceiling?.approvals || record.approvals || "native"; - } - return effectiveCeiling(lines, fleet, { swarm, rootApprovals, record, host: h }); + const rootApprovals = record && !opened ? rootApprovalsOf(rootOf(fleet, record, { env, lines })) : null; + return effectiveCeiling(lines, fleet, { swarm, rootApprovals, host: h }); } /* ---------------------------------------------------------------- the caller */ @@ -497,6 +568,11 @@ export function swarmId(task, { name = null, now = Date.now() } = {}) { /* ----------------------------------------------------------------- the fold */ +/** Whether the herd's roster is the one that can hold a member of this engine. */ +export function herdHolds(engine) { + return /^moshcode\//.test(engine || "") || engine === "tmux"; +} + /** Sum spend strings of matching units; "12 USD + 5000 tokens" when they differ. */ export function sumSpend(values) { const totals = new Map(); @@ -565,9 +641,12 @@ export function fold({ fleets = [], roster = null, host: h = host(), implicit = claimed: Boolean(start), started: start?.at || r?.started || null, end, - state: end ? end.state : start ? "running" : "unclaimed", + // `working` is the landing page's word for a claimed member with no + // end line; the herd's finer states (idle, blocked) ride on `live`. + state: end ? end.state : start ? "working" : "unclaimed", spend: spends.get(id) || null, live: null, + herdState: null, lost: false, rosterOnly: false, swarms: [], @@ -576,14 +655,17 @@ export function fold({ fleets = [], roster = null, host: h = host(), implicit = for (const r of records) if (r?.member && !members.has(r.member)) members.set(r.member, node(r, null)); for (const [id, s] of starts) if (!members.has(id)) members.set(id, node(null, s)); + // The herd roster can only hold a moshcode pane or a tmux target on this + // host, so only such a member it no longer lists is lost; a claude-code + // member, or a pane on another host, is nothing this roster can speak to. if (roster) { for (const m of members.values()) { - if (!(/^moshcode\//.test(m.engine || "") || m.engine === "tmux")) continue; + if (!herdHolds(m.engine) || m.host !== h) continue; const row = roster.find((x) => x.name === m.session || x.name === m.member); if (row) { matched.add(row.name); m.live = Boolean(row.alive); - if (!m.end && m.claimed && row.state) m.state = row.state; + m.herdState = row.state || null; } else if (m.claimed && !m.end) { m.state = "lost"; m.lost = true; @@ -655,7 +737,8 @@ export function fold({ fleets = [], roster = null, host: h = host(), implicit = impl.nodes.push({ kind: "member", member: row.name, session: row.name, title: null, task: null, engine: row.engine || null, host: h, cwd: row.cwd || null, depth: 0, parent: null, swarm: null, approvals: row.approvals || "native", owns: null, orphan: false, recorded: false, claimed: false, - started: null, end: null, state: row.state || (row.alive ? "running" : "gone"), spend: null, live: Boolean(row.alive), lost: false, rosterOnly: true, swarms: [], + started: null, end: null, state: row.state || (row.alive ? "working" : "gone"), spend: null, live: Boolean(row.alive), herdState: row.state || null, + lost: false, rosterOnly: true, swarms: [], }); } } @@ -673,34 +756,49 @@ const hhmm = (value) => { return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; }; -const clipText = (s, n) => (String(s).length > n ? `${String(s).slice(0, n - 3)}...` : String(s)); - -function memberLabel(m, h) { - const parts = [m.session && m.session !== m.member ? `${m.member} (${m.session})` : m.member]; - if (m.title) parts.push(m.title); - else if (m.rosterOnly && m.cwd) parts.push(m.cwd); - if (m.engine) parts.push(m.engine); - if (m.host && m.host !== h) parts.push(`@${m.host}`); - parts.push(m.state); - if (m.approvals === "bypass") parts.push("[bypass]"); - if (Array.isArray(m.owns) && m.owns.length) parts.push(`owns ${m.owns.join(",")}`); - if (m.orphan) parts.push("[orphan]"); - if (m.rosterOnly) parts.push("[roster]"); - if (m.spend) parts.push(`spent ${m.spend}`); - return parts.join(" "); -} - -function swarmLabel(s) { - const parts = [`swarm ${s.swarm}`]; - if (s.task) parts.push(`"${clipText(s.task, 40)}"`); - parts.push(`${s.members.length}${s.fan_out ? `/${s.fan_out}` : ""} member${s.members.length === 1 ? "" : "s"}`); - if (s.end) parts.push(s.end.state); - else if (s.state === "ended") parts.push("ended"); - else if (s.until) parts.push(`until ${hhmm(s.until)}`); - else parts.push("running"); - if (s.spend) parts.push(`spent ${s.spend}`); - if (s.missing) parts.push("[no swarm.spawn]"); - return parts.join(" "); +/** + * A swarm's task, quoted, clipped at 60 characters with ` ...`; bare when the + * task carries a double quote of its own, as a command line does. The same + * rule logicsrc's tree uses, so the two tools draw one swarm one way. + */ +function quoteTask(task, max = 60) { + const text = String(task); + const clipped = text.length > max ? `${text.slice(0, max - 4).trimEnd()} ...` : text; + return clipped.includes('"') ? clipped : `"${clipped}"`; +} + +/** A member row in three padded columns (label, title, engine) and the rest. */ +function memberRow(m, h) { + const marks = [m.state]; + if (m.approvals === "bypass") marks.push("[bypass]"); + if (m.orphan) marks.push("[orphan]"); + if (m.rosterOnly) marks.push("[roster]"); + if (m.live === false && m.state === "working") marks.push("[gone]"); + if (Array.isArray(m.owns) && m.owns.length) marks.push(`owns ${m.owns.join(",")}`); + if (m.host && m.host !== h) marks.push(`@${m.host}`); + if (m.spend) marks.push(`spent ${m.spend}`); + return { + label: m.session && m.session !== m.member ? `${m.member} (${m.session})` : String(m.member), + title: m.title || (m.rosterOnly && m.cwd ? m.cwd : ""), + engine: m.engine || "?", + rest: marks.join(" "), + }; +} + +function swarmRow(s) { + const rest = []; + if (s.end) rest.push(s.end.state); + else if (s.state === "ended") rest.push("ended"); + else if (s.until) rest.push(`until ${hhmm(s.until)}`); + else rest.push("running"); + if (s.spend) rest.push(`spent ${s.spend}`); + if (s.missing) rest.push("[no swarm.spawn]"); + return { + label: `swarm ${s.swarm}`, + title: s.task ? quoteTask(s.task) : "", + engine: `${s.members.length}${s.fan_out ? `/${s.fan_out}` : ""} member${s.members.length === 1 ? "" : "s"}`, + rest: rest.join(" "), + }; } function fleetLabel(f) { @@ -716,21 +814,34 @@ function fleetLabel(f) { return `${f.fleet} (${bits.join(", ")})`; } -/** The tree as text, in the shape of the spec's landing page. */ +/** + * The tree as text, in the shape of the spec's landing page: one fleet after + * another, the label, title and engine columns padded per fleet so the state + * column lines up, the way logicsrc's tree prints the same files. + */ export function renderTree(model, { host: h = host() } = {}) { const lines = []; - const draw = (nodes, prefix) => { - nodes.forEach((n, i) => { - const last = i === nodes.length - 1; - lines.push(`${prefix}${last ? "└─ " : "├─ "}${n.kind === "swarm" ? swarmLabel(n) : memberLabel(n, h)}`); - const children = n.kind === "swarm" ? n.members : n.swarms; - if (children?.length) draw(children, `${prefix}${last ? " " : "│ "}`); - }); - }; for (const f of model.fleets || []) { lines.push(fleetLabel(f)); - if (f.nodes?.length) draw(f.nodes, ""); - else lines.push("└─ (no members yet)"); + if (!f.nodes?.length) { lines.push("└─ (no members yet)"); continue; } + const rows = []; + const draw = (nodes, prefix) => { + nodes.forEach((n, i) => { + const last = i === nodes.length - 1; + rows.push({ prefix: `${prefix}${last ? "└─ " : "├─ "}`, ...(n.kind === "swarm" ? swarmRow(n) : memberRow(n, h)) }); + const children = n.kind === "swarm" ? n.members : n.swarms; + if (children?.length) draw(children, `${prefix}${last ? " " : "│ "}`); + }); + }; + draw(f.nodes, ""); + const labelWidth = Math.max(...rows.map((r) => r.prefix.length + r.label.length)); + const titleWidth = Math.max(...rows.map((r) => r.title.length)); + const engineWidth = Math.max(...rows.map((r) => r.engine.length)); + for (const r of rows) { + const head = `${r.prefix}${r.label}`.padEnd(labelWidth); + const title = titleWidth ? ` ${r.title.padEnd(titleWidth)}` : ""; + lines.push(`${head}${title} ${r.engine.padEnd(engineWidth)} ${r.rest}`.trimEnd()); + } } return lines.join("\n"); } diff --git a/src/swarm.mjs b/src/swarm.mjs index ff402e7d..598a8828 100644 --- a/src/swarm.mjs +++ b/src/swarm.mjs @@ -1,9 +1,9 @@ -// Swarm — one task, a herd of agents, one answer (PRD 0015, PRD 0016). +// Swarm: one task, a herd of agents, one answer (PRD 0015, PRD 0016). // // Claude Code calls it ultracode: a prompt that becomes a workflow of agents, // planned, fanned out, verified and synthesised. The herd already has every -// piece of that — sessions that outlive the terminal, `prompt --wait`, a task -// ledger with each session's output — and `moshscript` could already wire them +// piece of that: sessions that outlive the terminal, `prompt --wait`, a task +// ledger with each session's output. `moshscript` could already wire them // together by hand. This is the verb that does it for you, with any engine // moshcode can start, not one vendor's. // @@ -14,12 +14,12 @@ // a plan that does not parse degrades to one piece (the whole // task) rather than to nothing. // fan out one herd session per piece, `--agents` of them at a time -// (default 4 — the same number the claude engine's settings +// (default 4, the same number the claude engine's settings // defaults cap Claude's own workflows at). Each is prompted and // waited on exactly the way `moshcode herd prompt --wait` does, // so every piece is a task in the ledger with its output. // verify optional: a skeptic per piece, prompted to refute it. What it -// says is attached to the piece, never used to drop it — the +// says is attached to the piece, never used to drop it: the // synthesis sees both and the operator decides. // synthesise one more headless call folds the pieces into an answer. // @@ -66,7 +66,7 @@ function parseDuration(raw, fallback) { return { ms: n, s: n * 1000, m: n * 60000, h: n * 3600000 }[m[2] || "s"]; } -/** The flags, and the task — every positional word that is not one. */ +/** The flags, and the task: every positional word that is not one. */ export function parseSwarmArgs(argv = []) { const flags = { agents: DEFAULT_AGENTS, engine: null, cwd: process.cwd(), herd: "swarm", name: null, @@ -103,7 +103,7 @@ export function parseSwarmArgs(argv = []) { // The headless calls run in the operator's working directory, and an engine // in print mode still has its tools. Seen live: a synthesis asked to fold two // failed pieces into an answer went and did the task itself instead. The -// planner, the skeptic and the synthesis are asked to think, not act — the +// planner, the skeptic and the synthesis are asked to think, not act; the // agents in the herd are the ones that act. const NO_TOOLS = "Do not run commands, read or write files, or use any tool for this: answer from the text you are given, and nothing else."; @@ -137,7 +137,7 @@ export function verifyPrompt({ task, piece, output }) { export function synthesisPrompt({ task, results }) { const parts = results.map((r, i) => [ - `--- piece ${i + 1}: ${r.title} (${r.state}${r.verified ? `, review: ${r.verified.refuted ? "REFUTED" : "stands"} — ${r.verified.reason}` : ""}) ---`, + `--- piece ${i + 1}: ${r.title} (${r.state}${r.verified ? `, review: ${r.verified.refuted ? "REFUTED" : "stands"}: ${r.verified.reason}` : ""}) ---`, r.artifact || "(no output captured)", ].join("\n")); return [ @@ -208,9 +208,13 @@ export function summaryOf(text, { max = SUMMARY_CHARS } = {}) { return body.length > max ? body.slice(-max) : body; } -/** The herd's outcome for a piece, as the end state the ledger names. */ +/** + * The herd's outcome for a piece, as the end state the ledger names. A wait + * that matched on `blocked` is a pane asking a question, not one that + * finished its piece, so it is not `done`. + */ export function endStateOf(result) { - if (result.outcome === "matched") return "done"; + if (result.outcome === "matched") return result.state === "blocked" ? "failed" : "done"; if (result.outcome === "timeout") return "timeout"; if (result.outcome === "gone") return "lost"; return "failed"; @@ -284,7 +288,7 @@ export function liveDeps() { return { ai: (engine, prompt, { cwd, omitEnv } = {}) => runHeadless(engine, prompt, { cwd, omitEnv }), // The engine's autonomous-session flags, spelled out. NOT `--agent`: for - // an engine with an `agentsView` that opens its agents *overview* — the + // an engine with an `agentsView` that opens its agents *overview*, the // right screen for `/agents claude`, and a screen where a typed prompt // starts a background job somewhere else instead of working here. Seen // live: two pieces "finished" in 8s with a roster for output. @@ -299,12 +303,12 @@ export function liveDeps() { return code === EXIT.matched ? { ok: true } : { ok: false, error: lines.join(" ") || "could not start the session" }; }, // An engine takes a moment to draw its prompt; keystrokes typed before - // that are lost. Idle is "ready". A dialog before any work — "trust this - // folder?" on a directory the engine has not seen — is answered from the + // that are lost. Idle is "ready". A dialog before any work ("trust this + // folder?" on a directory the engine has not seen) is answered from the // engine's own boot spec, each one once, and then the wait resumes. // Anything the spec does not name is left alone and reported: guessing // at a dialog is how an agent ends up saying yes to something it should - // not have — or, with Claude's trust check, "No, exit". + // not have, or, with Claude's trust check, "No, exit". boot: (name, { engine }) => waitForPrompt(name, { engine }), // `herd prompt --wait`, with one difference: it will not take an idle // screen as "finished" until it has seen the engine work. herd prompt @@ -363,14 +367,14 @@ export async function throttled(items, limit, fn) { /** * The whole thing, as data. `write` gets the narration; the return value is - * what `--json` prints. Never throws for an engine's failure — a piece that + * what `--json` prints. Never throws for an engine's failure: a piece that * failed is a piece with `state: "failed"` and the synthesis says so. */ export async function runSwarm(options, { write = () => {}, deps = liveDeps(), engineOf = pickAiEngine, now = () => Date.now() } = {}) { const { task, agents, cwd, herd, verify, planOnly, keep, timeoutMs } = options; const engine = engineOf(options.engine); if (!engine) { - return { ok: false, error: options.engine ? `no installed engine named ${JSON.stringify(options.engine)}` : "no engine installed — moshcode install claude" }; + return { ok: false, error: options.engine ? `no installed engine named ${JSON.stringify(options.engine)}` : "no engine installed: moshcode install claude" }; } if (!Object.hasOwn(ENGINES, engine) || !ENGINES[engine].bin) return { ok: false, error: `no engine named ${JSON.stringify(engine)}` }; const fleetIO = deps.fleet || openfleet; @@ -394,14 +398,14 @@ export async function runSwarm(options, { write = () => {}, deps = liveDeps(), e const omitEnv = ["OPENFLEET_SWARM"]; // 1. plan - write(info(`plan — ${engine} is splitting the task into up to ${agents} pieces`)); + write(info(`plan: ${engine} is splitting the task into up to ${agents} pieces`)); let plan = null, planNote = null; try { const reply = deps.ai(engine, planPrompt({ task, agents, cwd }), { cwd, omitEnv }); plan = parsePlan(reply, { agents }); - if (!plan) planNote = "the plan did not parse — running the task as one piece"; + if (!plan) planNote = "the plan did not parse: running the task as one piece"; } catch (error) { - planNote = `planning failed (${error.message || error}) — running the task as one piece`; + planNote = `planning failed (${error.message || error}): running the task as one piece`; } if (!plan) plan = [{ title: "the whole task", prompt: `${task}\n\nEnd your work with a section headed SUMMARY: saying what you did and what you found.` }]; if (planNote) write(warn(planNote)); @@ -420,6 +424,15 @@ export async function runSwarm(options, { write = () => {}, deps = liveDeps(), e const ceiling = openfleet.mergeCeiling(ctx.ceiling, narrowing); const refusal = openfleet.checkCeiling({ depth, fan_out: plan.length, hosts: [hostname] }, ceiling, { now: startedAt }) || openfleet.checkCeiling({ approvals }, ceiling, { now: startedAt }); + // In the implicit fleet approvals enter at the root, and a member of a swarm + // the sysop started by hand is its own root: its record carries its own + // approvals in the ceiling, so every reader agrees on what it runs under. + if (!refusal && !parent && ceiling.approvals === undefined) ceiling.approvals = approvals; + // A deadline inherited from above is earlier than --timeout would allow: + // each prompt waits at most until it, so the swarm ends when the ceiling + // says rather than after it (rule 6). + const deadline = openfleet.untilMs(ceiling.until); + const waitMs = () => (deadline === null ? timeoutMs : Math.max(1, Math.min(timeoutMs, deadline - now()))); if (refusal) { const reason = `the ceiling refuses ${refusal.key}: wanted ${JSON.stringify(refusal.wanted)}, allowed ${JSON.stringify(refusal.allowed)}`; const line = { event: "ceiling.refuse", by, key: refusal.key, wanted: refusal.wanted, allowed: refusal.allowed }; @@ -468,13 +481,13 @@ export async function runSwarm(options, { write = () => {}, deps = liveDeps(), e }; const launch = deps.start(name, { engine, cwd, herd, env }); if (!launch.ok) { - write(err(`${name} — could not start: ${launch.error}`)); + write(err(`${name}: could not start: ${launch.error}`)); return row({ error: String(launch.error) }); } started.add(name); const boot = await deps.boot(name, { engine }); if (boot.outcome !== "matched") { - write(err(`${name} — never became ready (${boot.outcome}, ${boot.state})`)); + write(err(`${name}: never became ready (${boot.outcome}, ${boot.state})`)); return row({ outcome: boot.outcome, error: "the engine never became ready" }); } write(` ${ash("→")} ${bone(name)} ${ash(piece.title)}`); @@ -490,9 +503,9 @@ export async function runSwarm(options, { write = () => {}, deps = liveDeps(), e engine: engineString, cwd, approvals, piece: pieceOf(i), }, { now: at }); }; - const done = await deps.prompt(name, text, { timeoutMs, onSubmitted }); + const done = await deps.prompt(name, text, { timeoutMs: waitMs(), onSubmitted }); if (!done.ok) { - write(err(`${name} — ${done.error || "the prompt was not delivered"}`)); + write(err(`${name}: ${done.error || "the prompt was not delivered"}`)); return row({ task: done.task, outcome: done.outcome, artifact: done.artifact || "", error: done.error }); } const mark = done.outcome === "matched" ? acid("✓") : amber("~"); @@ -502,7 +515,7 @@ export async function runSwarm(options, { write = () => {}, deps = liveDeps(), e // 5. verify if (verify) { - write(info(`verify — one skeptic per piece (${engine})`)); + write(info(`verify: one skeptic per piece (${engine})`)); for (const r of results) { if (r.state === "failed") continue; try { @@ -516,7 +529,7 @@ export async function runSwarm(options, { write = () => {}, deps = liveDeps(), e } // 6. synthesise - write(info(`synthesis — ${engine} is folding ${results.length} piece${results.length === 1 ? "" : "s"} into one answer`)); + write(info(`synthesis: ${engine} is folding ${results.length} piece${results.length === 1 ? "" : "s"} into one answer`)); try { synthesis = deps.ai(engine, synthesisPrompt({ task, results: results.map((r) => ({ ...r, artifact: (r.artifact || r.error || "").slice(-PIECE_CHARS) })), @@ -563,7 +576,7 @@ export async function swarmCommand(argv = [], { write = console.log, deps, engin if (options.errors.length) { for (const e of options.errors) write(err(e)); write(err(USAGE)); return EXIT.usage; } if (!options.task) { write(err(USAGE)); return EXIT.usage; } if (options.engine && !resolveEngine(options.engine)) { - write(err(`no engine named ${JSON.stringify(options.engine)} — one of ${Object.keys(ENGINES).join(", ")}`)); + write(err(`no engine named ${JSON.stringify(options.engine)}: one of ${Object.keys(ENGINES).join(", ")}`)); return EXIT.usage; } @@ -572,7 +585,7 @@ export async function swarmCommand(argv = [], { write = console.log, deps, engin if (options.json) { write(JSON.stringify(result, null, 2)); return result.ok ? EXIT.matched : EXIT.usage; } if (!result.ok && !result.results?.length) { write(err(result.error)); return EXIT.usage; } - if (result.planOnly) { write(info("plan only — nothing was started.")); return EXIT.matched; } + if (result.planOnly) { write(info("plan only: nothing was started.")); return EXIT.matched; } const failed = result.results.filter((r) => r.state === "failed").length; write(""); diff --git a/test/fleet-cli.test.mjs b/test/fleet-cli.test.mjs index fc927919..21fbbc2f 100644 --- a/test/fleet-cli.test.mjs +++ b/test/fleet-cli.test.mjs @@ -26,8 +26,10 @@ function harness({ roster = [], env = {} } = {}) { env: { OPENFLEET_HOME: dir, ...env }, now: () => NOW, host: "dev", + // The seeded fleet is the implicit one on any box, not just the author's. + implicit: FLEET, roster: () => roster, - kill: async (name) => { kills.push(name); return roster.some((r) => r.name === name) ? { ok: true } : { ok: false, error: "no such session" }; }, + kill: async (name) => { kills.push(name); return (roster || []).some((r) => r.name === name) ? { ok: true } : { ok: false, error: "no such session" }; }, exec: (bin, args) => { execs.push([bin, ...args]); return { ok: true }; }, signal: (pid) => { signals.push(pid); return { ok: true }; }, }; @@ -49,6 +51,10 @@ function seed(h) { const rec = (r) => fleet.writeRecord({ openfleet: "0.1", fleet: FLEET, sysop: FLEET, host: "dev", ...r }, { env }); rec({ member: "460a4502", engine: "claude-code", depth: 0, approvals: "bypass", ceiling: { approvals: "bypass", depth: 1, hosts: ["dev"] } }); put({ event: "member.start", by: "460a4502", member: "460a4502", engine: "claude-code", depth: 0, approvals: "bypass" }, at("04:55:00")); + // The sysop raised the implicit fleet's depth so the second piece may spawn + // a swarm of its own: the spec's own answer to the depth-1 noise. A cap + // never opens the fleet, and naming no approvals leaves each root's own. + put({ event: "fleet.cap", by: "sysop", target: FLEET, ceiling: { depth: 2 } }, at("05:41:00")); put({ event: "swarm.spawn", by: "460a4502", swarm: "create-two-0541", task: "create two ...", ceiling: { fan_out: 4, until: at("06:11:01") }, pieces: [{ member: "create-two-0541-1", title: "create hello.sh bash", owns: ["hello.sh"] }, { member: "create-two-0541-2", title: "create bye.sh bash", owns: ["bye.sh"] }] }, at("05:41:01")); const ceiling = { approvals: "bypass", depth: 2, fan_out: 4, hosts: ["dev"], until: at("06:11:01") }; @@ -131,7 +137,7 @@ test("cap on a fleet writes fleet.cap and stops the members now above the ceilin try { seed(h); assert.equal(await h.run(["cap", FLEET, "--approvals", "native", "--depth", "2"]), 0); - const cap = h.ledger().find((l) => l.event === "fleet.cap"); + const cap = h.ledger().filter((l) => l.event === "fleet.cap").at(-1); assert.equal(cap.by, "sysop"); assert.equal(cap.target, FLEET); assert.deepEqual(cap.ceiling, { approvals: "native", depth: 2 }); @@ -147,6 +153,29 @@ test("cap on a fleet writes fleet.cap and stops the members now above the ceilin } finally { h.cleanup(); } }); +test("cap on the implicit fleet that names no approvals leaves each root's own in place, and no bypass root is stopped", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + assert.equal(await h.run(["cap", FLEET, "--depth", "2", "--json"]), 0); + const out = JSON.parse(h.lines.at(-1)); + assert.deepEqual(out.members, [], "a cap never opens a fleet, so absent approvals is not native here"); + assert.deepEqual(h.execs, []); + assert.deepEqual(h.kills, []); + const { env } = h.opts; + const lines = h.ledger(); + const root = fleet.ceilingOf(FLEET, fleet.readMember(FLEET, "460a4502", env), { env, lines, host: "dev" }); + assert.deepEqual(root, { depth: 2, hosts: ["dev"], approvals: "bypass" }, "the cap's depth took effect over the record's depth 1 copy; the root's approvals stayed"); + const pane = fleet.ceilingOf(FLEET, fleet.readMember(FLEET, "create-two-0541-2", env), { env, lines, host: "dev" }); + assert.equal(pane.approvals, "bypass"); + assert.equal(pane.depth, 2); + // Naming approvals is what changes them. + assert.equal(await h.run(["cap", FLEET, "--approvals", "native", "--depth", "2"]), 0); + assert.deepEqual(h.execs, [["claude", "stop", "460a4502"]]); + assert.deepEqual(h.kills, ["create-two-0541-2"]); + } finally { h.cleanup(); } +}); + test("cap on a swarm only narrows, and a widening is refused before anything is written", async () => { const h = harness({ roster: ROSTER }); try { @@ -176,15 +205,16 @@ test("tree draws the fleet from the records and the ledger, joined to the herd r seed(h); assert.equal(await h.run(["tree"]), 0); const text = h.text(); - assert.match(text, /^anthony@dev \(implicit fleet, sysop anthony@dev, depth 1, hosts dev\)$/m); - assert.match(text, /^├─ 460a4502 claude-code running \[bypass\]$/m); - assert.match(text, /^│ └─ swarm create-two-0541 "create two \.\.\." 2\/4 members until 06:11$/m); - assert.match(text, /^│ ├─ create-two-0541-1 \(172ffd83\) create hello\.sh bash claude-code done \[bypass\] owns hello\.sh$/m); - assert.match(text, /^│ └─ create-two-0541-2 create bye\.sh bash moshcode\/claude working \[bypass\] owns bye\.sh$/m, "state from the roster"); - assert.match(text, /^│ └─ swarm inner-0541 "inner" 1\/4 member until 06:11$/m); - assert.match(text, /^│ └─ inner-0541-1 inner piece moshcode\/codex working$/m); - assert.match(text, /^└─ scratch \/home\/anthony moshcode\/shell idle \[roster\]$/m, "a herd session with no record is a root, marked as from the roster"); + assert.match(text, /^anthony@dev \(implicit fleet, sysop anthony@dev, depth 2, hosts dev\)$/m, "the cap's depth, and still no fleet-level approvals"); + assert.match(text, /^├─ 460a4502\s+claude-code\s+working \[bypass\]$/m); + assert.match(text, /^│ └─ swarm create-two-0541\s+"create two \.\.\."\s+2\/4 members\s+until 06:11$/m); + assert.match(text, /^│ ├─ create-two-0541-1 \(172ffd83\)\s+create hello\.sh bash\s+claude-code\s+done \[bypass\] owns hello\.sh$/m); + assert.match(text, /^│ └─ create-two-0541-2\s+create bye\.sh bash\s+moshcode\/claude\s+working \[bypass\] owns bye\.sh$/m); + assert.match(text, /^│ └─ swarm inner-0541\s+"inner"\s+1\/4 member\s+until 06:11$/m); + assert.match(text, /^│ └─ inner-0541-1\s+inner piece\s+moshcode\/codex\s+working$/m); + assert.match(text, /^└─ scratch\s+\/home\/anthony\s+moshcode\/shell\s+idle \[roster\]$/m, "a herd session with no record is a root, marked as from the roster"); assert.equal(h.ledger().filter((l) => l.state === "lost").length, 0); + assert.equal(h.ledger().filter((l) => l.event === "member.end").length, 1, "nothing is past its until at 05:42, so nothing is enforced"); h.lines.length = 0; assert.equal(await h.run(["tree", "--json"]), 0); @@ -203,10 +233,20 @@ test("tree writes member.end lost for a claimed moshcode member the roster no lo const lost = h.ledger().filter((l) => l.event === "member.end" && l.state === "lost"); assert.deepEqual(lost.map((l) => l.member).sort(), ["create-two-0541-2", "inner-0541-1"]); assert.ok(lost.every((l) => l.by === "sysop")); - assert.match(h.text(), /create-two-0541-2 create bye\.sh bash moshcode\/claude lost/); + assert.match(h.text(), /create-two-0541-2\s+create bye\.sh bash\s+moshcode\/claude\s+lost/); + assert.ok(fs.existsSync(path.join(h.dir, "fleets", FLEET, "marks", "member.end.create-two-0541-2.lost")), "under the lost marker, so a real end can still supersede it"); h.lines.length = 0; assert.equal(await h.run(["tree"]), 0); assert.equal(h.ledger().filter((l) => l.state === "lost").length, 2, "written once, not on every look"); + assert.equal(h.ledger().filter((l) => l.member === "460a4502" && l.event === "member.end").length, 0, "a claude-code member is never lost by the herd's roster"); + + const unread = harness({ roster: null }); + try { + seed(unread); + assert.equal(await unread.run(["tree"]), 0); + assert.equal(unread.ledger().filter((l) => l.state === "lost").length, 0, "an unreadable manifest says nothing about any pane"); + assert.match(unread.text(), /create-two-0541-2\s+create bye\.sh bash\s+moshcode\/claude\s+working/); + } finally { unread.cleanup(); } const agent = harness({ roster: [], env: { OPENFLEET_MEMBER: "460a4502" } }); try { @@ -217,6 +257,71 @@ test("tree writes member.end lost for a claimed moshcode member the roster no lo } finally { h.cleanup(); } }); +test("tree enforces the clock: a working member past its effective until is stopped through its engine with member.end timeout, then its swarm ends", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + // 06:20, past the 06:11:01 the spawn narrowed to. The nested swarm inherits it. + h.opts.now = () => Date.UTC(2026, 8, 13, 6, 20, 0); + assert.equal(await h.run(["tree"]), 0); + assert.deepEqual(h.kills, ["inner-0541-1", "create-two-0541-2"], "nested first, through the herd"); + assert.deepEqual(h.execs, [], "the root sits under the implicit fleet, which has no until"); + const tail = h.ledger().slice(-4).map((l) => `${l.event}:${l.member || l.swarm}:${l.state}:${l.by}`); + assert.deepEqual(tail, [ + "member.end:inner-0541-1:timeout:sysop", + "member.end:create-two-0541-2:timeout:sysop", + "swarm.end:inner-0541:timeout:sysop", + "swarm.end:create-two-0541:timeout:sysop", + ]); + assert.match(h.text(), /create-two-0541-2\s+create bye\.sh bash\s+moshcode\/claude\s+timeout/); + assert.match(h.text(), /create-two-0541-2 stopped as timeout \(past its until\)/); + assert.match(h.text(), /swarm create-two-0541 ended timeout/); + + h.lines.length = 0; + const before = h.ledger().length; + assert.equal(await h.run(["tree", "--json"]), 0); + assert.equal(h.ledger().length, before, "once ended, nothing more is written"); + assert.equal(JSON.parse(h.text()).enforced, undefined); + } finally { h.cleanup(); } +}); + +test("tree enforces the budget: members under a swarm or fleet whose summed member.spend has reached its budget are stopped with member.end budget", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + const { env } = h.opts; + const put = (line, when) => fleet.append(FLEET, { ...line, at: when }, { env, host: "dev" }); + put({ event: "fleet.cap", by: "sysop", target: "create-two-0541", ceiling: { budget: "1000 tokens" } }, at("05:41:50")); + put({ event: "member.spend", by: "create-two-0541-2", member: "create-two-0541-2", amount: "700 tokens", total: "700 tokens" }, at("05:41:51")); + put({ event: "member.spend", by: "inner-0541-1", member: "inner-0541-1", amount: "2 USD", total: "2 USD" }, at("05:41:52")); + assert.equal(await h.run(["tree"]), 0); + assert.deepEqual(h.kills, [], "700 tokens under 1000, and 2 USD is another unit: not counted"); + put({ event: "member.spend", by: "inner-0541-1", member: "inner-0541-1", amount: "300 tokens", total: "300 tokens" }, at("05:41:53")); + h.lines.length = 0; + assert.equal(await h.run(["tree", "--json"]), 0); + assert.deepEqual(h.kills, ["inner-0541-1", "create-two-0541-2"], "the nested member's spend counts against the swarm above it"); + const ends = h.ledger().filter((l) => l.event === "member.end" && l.state === "budget").map((l) => l.member); + assert.deepEqual(ends, ["inner-0541-1", "create-two-0541-2"]); + assert.deepEqual(h.ledger().filter((l) => l.event === "swarm.end").map((l) => [l.swarm, l.state]), [["inner-0541", "budget"], ["create-two-0541", "budget"]]); + const model = JSON.parse(h.text()); + assert.deepEqual(model.enforced.members.map((m) => [m.member, m.outcome, m.over]), [["inner-0541-1", "stopped", "budget"], ["create-two-0541-2", "stopped", "budget"]]); + + // A fleet's own budget, on an opened fleet, over a claude -p member. + const g = harness({ roster: [] }); + try { + assert.equal(await g.run(["open", "team", "--budget", "10 USD"]), 0); + const e = g.opts.env; + fleet.writeRecord({ openfleet: "0.1", fleet: "team-20260913", sysop: FLEET, member: "p-1", engine: "claude-p", session: "4242", host: "dev" }, { env: e }); + fleet.append("team-20260913", { event: "member.start", by: "p-1", member: "p-1", engine: "claude-p", session: "4242" }, { env: e, host: "dev" }); + fleet.append("team-20260913", { event: "member.spend", by: "p-1", member: "p-1", amount: "10 USD", total: "10 USD" }, { env: e, host: "dev" }); + assert.equal(await g.run(["tree"]), 0); + assert.deepEqual(g.signals, [4242]); + assert.equal(fleet.endOf(g.ledger("team-20260913"), "p-1").state, "budget"); + assert.match(g.text(), /p-1 stopped as budget \(over budget\)/); + } finally { g.cleanup(); } + } finally { h.cleanup(); } +}); + test("an empty home says so, and a bare `fleet` is the tree", async () => { const h = harness(); try { @@ -257,6 +362,68 @@ test("stop on a swarm ends nested swarms first, then its members through their e } finally { h.cleanup(); } }); +test("stop leaves a swarm open when a member's engine would not let go: no end line for it, no swarm.end, exit 3", async () => { + const h = harness({ roster: ROSTER }); + try { + seed(h); + h.opts.kill = async (name) => { h.kills.push(name); return name === "create-two-0541-2" ? { ok: false, error: "tmux said no" } : { ok: true }; }; + assert.equal(await h.run(["stop", "create-two-0541"]), 3); + assert.deepEqual(h.kills, ["inner-0541-1", "create-two-0541-2"]); + assert.equal(fleet.endOf(h.ledger(), "create-two-0541-2"), null, "an end line the engine did not honour is a lie"); + assert.deepEqual(h.ledger().filter((l) => l.event === "swarm.end").map((l) => l.swarm), ["inner-0541"], "the nested swarm, whose member did stop, still ends"); + assert.match(h.text(), /create-two-0541-2: tmux said no/); + assert.match(h.text(), /swarm create-two-0541 left open: no end line yet for create-two-0541-2/); + // Once the member has ended, a second stop closes the swarm. + h.opts.kill = async (name) => { h.kills.push(name); return { ok: true }; }; + h.lines.length = 0; + assert.equal(await h.run(["stop", "create-two-0541", "--json"]), 0); + const out = JSON.parse(h.text()); + assert.deepEqual(out.swarms.map((s) => [s.swarm, s.state]), [["inner-0541", "already-ended"], ["create-two-0541", "stopped"]]); + assert.equal(h.ledger().filter((l) => l.event === "swarm.end").length, 2); + } finally { h.cleanup(); } +}); + +test("stop ends a claude code member by its job id: the member id when it is one, else the first eight of a session UUID; an interactive session has none", async () => { + const h = harness({ roster: [] }); + try { + const { env } = h.opts; + const rec = (r) => fleet.writeRecord({ openfleet: "0.1", fleet: FLEET, sysop: FLEET, host: "dev", engine: "claude-code", ...r }, { env }); + const start = (member, session) => fleet.append(FLEET, { event: "member.start", by: member, member, session, engine: "claude-code", depth: 0 }, { env, host: "dev" }); + rec({ member: "job-0541-1" }); + start("job-0541-1", "172ffd83-3a5f-4c1e-9b2d-0123456789ab"); + assert.equal(await h.run(["stop", "job-0541-1"]), 0); + assert.deepEqual(h.execs, [["claude", "stop", "172ffd83"]], "the hooks write the full session id; claude stop takes the job id"); + assert.equal(fleet.endOf(h.ledger(), "job-0541-1").state, "stopped"); + + const uuid = "9c0d5b2a-1111-4222-8333-444455556666"; + rec({ member: uuid }); + start(uuid, uuid); + h.execs.length = 0; + assert.equal(await h.run(["stop", uuid]), 3); + assert.deepEqual(h.execs, [], "nothing this tool runs can end an interactive session"); + assert.match(h.text(), /no job id/); + assert.equal(fleet.endOf(h.ledger(), uuid), null); + } finally { h.cleanup(); } +}); + +test("stop on a member whose record was written but never claimed writes nothing and says so", async () => { + const h = harness({ roster: [] }); + try { + seed(h); + fleet.writeRecord({ openfleet: "0.1", fleet: FLEET, sysop: FLEET, host: "dev", member: "create-two-0541-3", swarm: "create-two-0541", parent: "460a4502", depth: 1, engine: "moshcode/claude", session: "create-two-0541-3" }, { env: h.opts.env }); + const before = h.ledger().length; + assert.equal(await h.run(["stop", "create-two-0541-3"]), 3); + assert.deepEqual(h.kills, [], "not pushed through the engine"); + assert.equal(h.ledger().length, before, "and never lost: it never started"); + assert.match(h.text(), /create-two-0541-3 never started: nothing to end/); + // Inside its swarm it is skipped, and does not hold the swarm open. + h.opts.roster = () => ROSTER; + h.opts.kill = async (name) => { h.kills.push(name); return { ok: true }; }; + assert.equal(await h.run(["stop", "create-two-0541"]), 0); + assert.equal(h.ledger().find((l) => l.event === "swarm.end" && l.swarm === "create-two-0541").state, "stopped"); + } finally { h.cleanup(); } +}); + test("stop on a member ends that member and nothing else; an ended member writes nothing", async () => { const h = harness({ roster: ROSTER }); try { @@ -276,11 +443,19 @@ test("stop on a member ends that member and nothing else; an ended member writes }); test("stop marks a member lost when its engine no longer has it, and reports one it cannot reach", async () => { + const unread = harness({ roster: null }); + try { + seed(unread); + assert.equal(await unread.run(["stop", "inner-0541-1"]), 3); + assert.equal(fleet.endOf(unread.ledger(), "inner-0541-1"), null, "with no roster to say otherwise, a failed kill is a failure, not a loss"); + } finally { unread.cleanup(); } const h = harness({ roster: [] }); try { seed(h); assert.equal(await h.run(["stop", "inner-0541-1"]), 0); assert.equal(h.ledger().at(-1).state, "lost"); + assert.equal(await h.run(["stop", "inner-0541-1"]), 0); + assert.match(h.text(), /had already ended \(lost\)/); fleet.writeRecord({ openfleet: "0.1", fleet: FLEET, sysop: FLEET, member: "odd-1", engine: "gemini-cli", session: "x" }, { env: h.opts.env }); fleet.append(FLEET, { event: "member.start", by: "odd-1", member: "odd-1", engine: "gemini-cli" }, { env: h.opts.env, host: "dev" }); assert.equal(await h.run(["stop", "odd-1"]), 3); @@ -346,8 +521,9 @@ test("log reads the ledger in order and filters by member, swarm and since; --js const all = h.lines; assert.equal(all.length, h.ledger().length); assert.match(all[0], /^2026-09-13T04:55:00Z member\.start\s+by 460a4502 460a4502 · claude-code · bypass$/); - assert.match(all[1], /swarm\.spawn\s+by 460a4502 create-two-0541 "create two \.\.\." · 2 pieces · fan_out 4, until 2026-09-13T06:11:01Z/); - assert.match(all[4], /member\.end\s+by create-two-0541-1 create-two-0541-1 · done · Created hello\.sh\./); + assert.match(all[1], /fleet\.cap\s+by sysop anthony@dev · depth 2/); + assert.match(all[2], /swarm\.spawn\s+by 460a4502 create-two-0541 "create two \.\.\." · 2 pieces · fan_out 4, until 2026-09-13T06:11:01Z/); + assert.match(all[5], /member\.end\s+by create-two-0541-1 create-two-0541-1 · done · Created hello\.sh\./); h.lines.length = 0; assert.equal(await h.run(["log", "--member", "create-two-0541-1"]), 0); diff --git a/test/herd-cli.test.mjs b/test/herd-cli.test.mjs index 9dbe4545..64368ebc 100644 --- a/test/herd-cli.test.mjs +++ b/test/herd-cli.test.mjs @@ -8,7 +8,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { - EXIT, carriesBypass, groupByFleet, herdRun, humanAge, paintState, parseStartArgs, renderRoster, shouldNotify, splitDetachArgs, waitFor, + EXIT, carriesBypass, groupByFleet, herdRun, humanAge, paintState, parseStartArgs, renderRoster, restoreEnv, shouldNotify, splitDetachArgs, waitFor, } from "../src/herd-cli.mjs"; import { ENGINES } from "../src/engines.mjs"; import { strip } from "../src/ui.mjs"; @@ -216,6 +216,20 @@ test("ages read as durations, not milliseconds", () => { /* ------------------------------------------------------------- the fleet */ +test("restore hands a fresh session only the fleet's home and id, and a resumed one everything it had (PRD 0016)", () => { + // A dead session's record is claimed; a fresh session under it would have + // to derive a child and, at depth 1 in the implicit fleet, be refused on + // its first prompt. Only --resume is the same member again. + const env = { OPENFLEET_HOME: "/h", OPENFLEET_RECORD: "/h/r.json", OPENFLEET_FLEET: "f", OPENFLEET_MEMBER: "m", OPENFLEET_SWARM: "s", OTHER: "x" }; + assert.deepEqual(restoreEnv(env), { + env: { OPENFLEET_HOME: "/h", OPENFLEET_FLEET: "f", OTHER: "x" }, + dropped: ["OPENFLEET_RECORD", "OPENFLEET_MEMBER", "OPENFLEET_SWARM"], + }); + assert.deepEqual(restoreEnv(env, { resumed: true }), { env, dropped: [] }); + assert.deepEqual(restoreEnv(undefined), { env: {}, dropped: [] }); + assert.deepEqual(restoreEnv({ OPENFLEET_HOME: "/h" }), { env: { OPENFLEET_HOME: "/h" }, dropped: [] }, "nothing to drop, nothing said"); +}); + test("carriesBypass is true for --agent and for the engine's own bypass flags passed as plain args", () => { // The untruthful `agent: false` the spec called out: a swarm pane runs // `claude --dangerously-skip-permissions` through plain args, and the diff --git a/test/openfleet.test.mjs b/test/openfleet.test.mjs index 982a9462..a01943ce 100644 --- a/test/openfleet.test.mjs +++ b/test/openfleet.test.mjs @@ -9,9 +9,10 @@ import os from "node:os"; import path from "node:path"; import { - CEILING_KEYS, append, checkCeiling, claimedBy, context, currentFleet, effectiveCeiling, endOf, findEvents, fleetCeiling, fold, - hasEvent, implicitFleet, isNarrower, iso, ledgerPaths, listFleets, listRecords, mergeCeiling, narrowingOf, parseBudget, readLedger, - readMember, readRecord, recordPath, renderTree, sumSpend, swarmChain, swarmEndState, swarmId, writeCurrent, writeRecord, + CEILING_KEYS, append, appendOnce, checkCeiling, claimedBy, context, currentFleet, effectiveCeiling, endOf, findEvents, fleetCeiling, fold, + hasEvent, herdHolds, host, implicitFleet, isNarrower, iso, ledgerPaths, listFleets, listRecords, markPath, markerFor, mergeCeiling, narrowingOf, + parseBudget, readLedger, readMember, readRecord, recordPath, renderTree, rootApprovalsOf, rootOf, sumSpend, swarmChain, swarmEndState, swarmId, + writeCurrent, writeRecord, } from "../src/openfleet.mjs"; import { NAME_RE } from "../src/herd.mjs"; @@ -137,6 +138,48 @@ test("a record with no member.start is unclaimed; the first start claims it", () }); }); +test("member.start, member.end and swarm.end take a once-marker first, so two writers that both found nothing cannot both append", () => { + withHome((dir) => { + // The race: a claude pane's hook and the moshcode that started it both + // check the ledger, find no member.start, and append. The marker is the + // exclusion, at the path logicsrc takes too. + const first = appendOnce("f", { event: "member.start", by: "m-1", member: "m-1", session: "a" }, { host: "dev" }); + assert.equal(first.already, false); + assert.equal(first.line.event, "member.start"); + const marker = markPath("f", "member.start.m-1"); + assert.equal(marker, path.join(dir, "fleets", "f", "marks", "member.start.m-1")); + assert.equal(mode(marker), 0o600); + assert.equal(mode(path.dirname(marker)), 0o700); + const second = appendOnce("f", { event: "member.start", by: "sysop", member: "m-1", session: "b" }, { host: "dev" }); + assert.deepEqual(second, { line: null, already: true }); + assert.equal(append("f", { event: "member.start", by: "sysop", member: "m-1" }), null, "append says nothing more than null"); + assert.equal(readLedger("f").filter((l) => l.event === "member.start").length, 1); + assert.equal(claimedBy(readLedger("f"), "m-1").session, "a"); + + // lost takes its own marker, so the engine's real end still lands and supersedes it; then nothing else does. + assert.equal(markerFor({ event: "member.end", member: "m-1", state: "lost" }), "member.end.m-1.lost"); + assert.equal(markerFor({ event: "member.end", member: "m-1", state: "done" }), "member.end.m-1"); + assert.equal(appendOnce("f", { event: "member.end", by: "sysop", member: "m-1", state: "lost" }).already, false); + assert.equal(appendOnce("f", { event: "member.end", by: "m-1", member: "m-1", state: "done" }).already, false); + assert.equal(appendOnce("f", { event: "member.end", by: "sysop", member: "m-1", state: "stopped" }).already, true); + assert.equal(appendOnce("f", { event: "member.end", by: "sysop", member: "m-1", state: "lost" }).already, true, "lost is written once as well"); + assert.equal(endOf(readLedger("f"), "m-1").state, "done"); + assert.equal(readLedger("f").filter((l) => l.event === "member.end").length, 2); + + assert.equal(markerFor({ event: "swarm.end", swarm: "s" }), "swarm.end.s"); + assert.equal(appendOnce("f", { event: "swarm.end", by: "sysop", swarm: "s", state: "done" }).already, false); + assert.equal(appendOnce("f", { event: "swarm.end", by: "sysop", swarm: "s", state: "failed" }).already, true); + assert.equal(readLedger("f").filter((l) => l.event === "swarm.end").length, 1); + + // Everything else has no marker and appends as the ledger allows. + assert.equal(markerFor({ event: "swarm.spawn", swarm: "s" }), null); + assert.equal(append("f", { event: "member.spend", by: "m-1", member: "m-1", amount: "1 USD", total: "1 USD" }).event, "member.spend"); + assert.equal(append("f", { event: "member.spend", by: "m-1", member: "m-1", amount: "1 USD", total: "2 USD" }).event, "member.spend"); + assert.equal(appendOnce("f", { event: "member.end", by: "sysop", member: "m-9", state: "done" }, { once: false }).already, false); + assert.equal(appendOnce("f", { event: "member.end", by: "sysop", member: "m-9", state: "done" }, { once: false }).already, false, "once: false skips the marker"); + }); +}); + test("one end line counts: the first written, except lost, which a real end supersedes", () => { const lines = [ { event: "member.end", member: "a", state: "lost" }, @@ -162,11 +205,17 @@ test("a swarm ends done only when every member did, else the first failure state /* ------------------------------------------------------------ the ceiling */ -test("a ceiling merges key by key, and unknown keys ride along", () => { +test("a ceiling merges key by key, never widens, and unknown keys ride along", () => { const fleet = { approvals: "bypass", depth: 2, hosts: ["dev"], budget: "20 USD" }; assert.deepEqual(mergeCeiling(fleet, { fan_out: 4, until: "2026-09-13T06:11:01Z" }), { ...fleet, fan_out: 4, until: "2026-09-13T06:11:01Z" }); assert.deepEqual(mergeCeiling(fleet, {}), fleet); assert.deepEqual(mergeCeiling(fleet, { depth: null, approvals: undefined }), fleet, "null and undefined are not narrowings"); + assert.deepEqual(mergeCeiling(fleet, { approvals: "native", depth: 1, budget: "5 USD" }), { ...fleet, approvals: "native", depth: 1, budget: "5 USD" }); + // A ledger line that widens is ignored key by key (rule 3): the reader trusts no writer to widen. + assert.deepEqual(mergeCeiling({ approvals: "native", depth: 1, fan_out: 2 }, { approvals: "bypass", depth: 3, fan_out: 8 }), { approvals: "native", depth: 1, fan_out: 2 }); + assert.deepEqual(mergeCeiling({ hosts: ["dev"], until: "2026-09-13T06:00:00Z", budget: "20 USD" }, { hosts: ["dev", "mars"], until: "2026-09-13T07:00:00Z", budget: "30 USD", note: "kept" }), + { hosts: ["dev"], until: "2026-09-13T06:00:00Z", budget: "20 USD", note: "kept" }, "unknown keys ride along"); + assert.deepEqual(mergeCeiling({}, { approvals: "bypass", depth: 2, fan_out: 4 }), { fan_out: 4 }, "absent approvals means native and absent depth means 1, so neither enters by a spawn line; absent fan_out is uncapped"); assert.deepEqual(CEILING_KEYS, ["approvals", "budget", "depth", "fan_out", "hosts", "until"]); }); @@ -223,6 +272,9 @@ test("checkCeiling names the first key that would be exceeded, with both values" assert.equal(parseBudget("lots"), null); }); +/** A cap on the implicit fleet, which never opens it. */ +const IMPLICIT_CAP = { at: "2026-09-13T05:30:00Z", event: "fleet.cap", fleet: "anthony@dev", host: "dev", by: "sysop", target: "anthony@dev", ceiling: { depth: 2 } }; + test("a fleet's ceiling is the latest cap on it, else fleet.open, else the implicit fleet's", () => { const implicit = fleetCeiling([], "anthony@dev", { host: "dev" }); assert.deepEqual(implicit, { ceiling: { depth: 1, hosts: ["dev"] }, opened: false, line: null }); @@ -235,8 +287,15 @@ test("a fleet's ceiling is the latest cap on it, else fleet.open, else the impli const cap = { at: "2026-09-13T05:30:00Z", event: "fleet.cap", fleet: "f", host: "dev", by: "sysop", target: "f", ceiling: { approvals: "native", depth: 3 } }; const other = { ...cap, at: "2026-09-13T05:20:00Z", target: "some-swarm", ceiling: { depth: 0 } }; assert.deepEqual(fleetCeiling([open, other, cap], "f", { host: "dev" }).ceiling, { approvals: "native", depth: 3, hosts: ["dev"] }, "the latest cap on the fleet wins whole"); + assert.deepEqual(fleetCeiling([open, { ...cap, ceiling: { depth: 3 } }], "f", { host: "dev" }).ceiling, { approvals: "native", depth: 3, hosts: ["dev"] }, "a cap on an opened fleet with no approvals means native too"); + // A cap never opens a fleet: capped, the implicit fleet still has no fleet-level approvals unless the cap named them. + const capped = fleetCeiling([IMPLICIT_CAP], "anthony@dev", { host: "dev" }); + assert.equal(capped.opened, false); + assert.deepEqual(capped.ceiling, { depth: 2, hosts: ["dev"] }); + assert.deepEqual(fleetCeiling([{ ...IMPLICIT_CAP, ceiling: { approvals: "native" } }], "anthony@dev", { host: "dev" }).ceiling, { approvals: "native", depth: 1, hosts: ["dev"] }); }); + test("the effective ceiling merges the fleet's with every spawn on the path, a cap on a swarm applied last", () => { const lines = [ { at: "2026-09-13T05:00:00Z", event: "fleet.open", fleet: "f", host: "dev", by: "sysop", sysop: "s", ceiling: { approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] } }, @@ -251,6 +310,18 @@ test("the effective ceiling merges the fleet's with every spawn on the path, a c assert.deepEqual(effectiveCeiling(lines, "f", { host: "dev" }), { approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] }); // The implicit fleet: approvals enter at the root, from the flags it was started with. assert.deepEqual(effectiveCeiling([], "anthony@dev", { rootApprovals: "bypass", host: "dev" }), { depth: 1, hosts: ["dev"], approvals: "bypass" }); + // A cap on the implicit fleet that names no approvals leaves the root's in place; one that names them wins. + assert.deepEqual(effectiveCeiling([IMPLICIT_CAP], "anthony@dev", { rootApprovals: "bypass", host: "dev" }), { depth: 2, hosts: ["dev"], approvals: "bypass" }); + assert.equal(effectiveCeiling([{ ...IMPLICIT_CAP, ceiling: { approvals: "native" } }], "anthony@dev", { rootApprovals: "bypass", host: "dev" }).approvals, "native"); + // The sysop's cap on an ancestor swarm is applied after every spawn on the path, so a descendant's later, wider until does not undo it. + const capUntil = { at: "2026-09-13T05:55:00Z", event: "fleet.cap", fleet: "f", host: "dev", by: "sysop", target: "outer-0541", ceiling: { until: "2026-09-13T06:00:00Z" } }; + const innerLater = { ...lines[2], ceiling: { fan_out: 2, until: "2026-09-13T06:05:00Z" } }; + assert.equal(effectiveCeiling([lines[0], lines[1], innerLater, capUntil], "f", { swarm: "inner-0545", host: "dev" }).until, "2026-09-13T06:00:00Z"); + // A spawn line that widens is ignored: the fleet said native and depth 2, whatever the child wrote. + const widening = { ...lines[2], ceiling: { fan_out: 8, approvals: "bypass", depth: 9 } }; + assert.deepEqual(effectiveCeiling([{ ...lines[0], ceiling: { approvals: "native", depth: 2 } }, lines[1], widening], "f", { swarm: "inner-0545", host: "dev" }), { + approvals: "native", depth: 2, hosts: ["dev"], fan_out: 4, until: "2026-09-13T06:11:01Z", + }); }); /* ------------------------------------------------------------- the caller */ @@ -287,7 +358,7 @@ test("context resolves the fleet from the record, else OPENFLEET_FLEET, else cur assert.equal(own.agent, true); assert.equal(own.by, "460a4502"); assert.equal(own.depth, 0); - assert.deepEqual(own.ceiling, { approvals: "bypass", depth: 1, hosts: ["dev"] }); + assert.deepEqual(own.ceiling, { approvals: "bypass", depth: 1, hosts: [host()] }, "the fleet's ceiling with the root's own approvals; the record's copy is a snapshot, not an input"); }); }); @@ -302,7 +373,22 @@ test("inside a member of the implicit fleet, approvals come from the root of the assert.equal(context().ceiling.approvals, "native", "a root that carries orphan gets native"); const missing = writeRecord({ openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "lonely", parent: "nobody", depth: 1, ceiling: { approvals: "bypass", depth: 1 } }); process.env.OPENFLEET_RECORD = missing.path; - assert.equal(context().ceiling.approvals, "bypass", "with the chain broken, the record's own ceiling is the witness"); + assert.equal(context().ceiling.approvals, "native", "with the chain broken nothing vouches for bypass, whatever the record copied"); + // A parent with no record on this host but a member.start line still vouches. + append("anthony@dev", { event: "member.start", by: "elsewhere", member: "elsewhere", engine: "claude-code", depth: 0, approvals: "bypass" }, { host: "netcup" }); + const viaStart = writeRecord({ openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "elsewhere-1-1", parent: "elsewhere", depth: 1 }); + process.env.OPENFLEET_RECORD = viaStart.path; + assert.equal(context().ceiling.approvals, "bypass"); + assert.equal(rootOf("anthony@dev", readRecord(viaStart.path)).member, "elsewhere"); + assert.equal(rootOf("anthony@dev", readRecord(missing.path)), null); + // A parentless record whose ceiling lacks approvals is its own root and supplies its own, never native by default. + const byHand = writeRecord({ openfleet: "0.1", fleet: "anthony@dev", sysop: "anthony@dev", member: "byhand-0541-1", swarm: "byhand-0541", depth: 0, approvals: "bypass", ceiling: { depth: 1, hosts: [host()] } }); + process.env.OPENFLEET_RECORD = byHand.path; + assert.equal(context().ceiling.approvals, "bypass"); + assert.equal(rootApprovalsOf({ approvals: "bypass" }), "bypass"); + assert.equal(rootApprovalsOf({ approvals: "bypass", orphan: true }), "native"); + assert.equal(rootApprovalsOf({}), "native"); + assert.equal(rootApprovalsOf(null), "native"); }); }); @@ -356,7 +442,7 @@ test("fold turns the worked example into the tree on the landing page", () => { assert.deepEqual(f.nodes.map((n) => n.kind), ["member"], "one root, and the swarms hang under it"); const root = f.nodes[0]; assert.equal(root.member, "460a4502"); - assert.equal(root.state, "running"); + assert.equal(root.state, "working", "the landing page's word for a claimed member with no end line"); assert.equal(root.approvals, "bypass"); assert.deepEqual(root.swarms.map((s) => s.swarm), ["460a4502-1", "create-two-0541"]); const [planner, swarm] = root.swarms; @@ -374,21 +460,29 @@ test("fold turns the worked example into the tree on the landing page", () => { assert.equal(one.spend, "1200 tokens"); assert.deepEqual(one.owns, ["hello.sh"]); assert.equal(one.title, "create hello.sh bash"); - assert.equal(two.state, "running"); + assert.equal(two.state, "working"); assert.equal(two.engine, "moshcode/claude"); assert.equal(two.claimed, true); assert.equal(swarm.spend, "1200 tokens"); + // The text is the landing page's shape, columns padded per fleet the way + // logicsrc's tree prints the same files. const text = renderTree(model, { host: "dev" }); const lines = text.split("\n"); assert.equal(lines[0], "anthony@dev (implicit fleet, sysop anthony@dev, depth 1, hosts dev, spent 1200 tokens)"); - assert.match(lines[1], /^└─ 460a4502 claude-code running \[bypass\]$/); - assert.match(lines[2], /^ ├─ swarm 460a4502-1 "claude -p "Split the task below into \.\.\." 1 member done$/, "a long task is clipped to 40 characters"); - assert.match(lines[3], /^ │ └─ 460a4502-1-1 \(31337\) claude-p done \[bypass\]$/); - assert.match(lines[4], /^ └─ swarm create-two-0541 "create two \.\.\." 2\/4 members until 06:11 spent 1200 tokens$/); - assert.match(lines[5], /^ ├─ create-two-0541-1 \(172ffd83\) create hello\.sh bash claude-code done \[bypass\] owns hello\.sh spent 1200 tokens$/); - assert.match(lines[6], /^ └─ create-two-0541-2 create bye\.sh bash moshcode\/claude running \[bypass\] owns bye\.sh$/); + assert.match(lines[1], /^└─ 460a4502\s+claude-code\s+working \[bypass\]$/); + assert.match(lines[2], /^ ├─ swarm 460a4502-1\s+claude -p "Split the task below into at most 4 \.\.\."\s+1 member\s+done$/, "a task that carries a quote reads bare"); + assert.match(lines[3], /^ │ └─ 460a4502-1-1 \(31337\)\s+claude-p\s+done \[bypass\]$/); + assert.match(lines[4], /^ └─ swarm create-two-0541\s+"create two \.\.\."\s+2\/4 members\s+until 06:11 spent 1200 tokens$/); + assert.match(lines[5], /^ ├─ create-two-0541-1 \(172ffd83\)\s+create hello\.sh bash\s+claude-code\s+done \[bypass\] owns hello\.sh spent 1200 tokens$/); + assert.match(lines[6], /^ └─ create-two-0541-2\s+create bye\.sh bash\s+moshcode\/claude\s+working \[bypass\] owns bye\.sh$/); assert.equal(lines.length, 7); + const engineAt = lines.slice(5, 7).map((l) => l.indexOf("claude-code") >= 0 ? l.indexOf("claude-code") : l.indexOf("moshcode/claude")); + assert.equal(engineAt[0], engineAt[1], "the engine column lines up"); + const long = renderTree(fold({ fleets: [{ fleet: "f", lines: [ + { at: "2026-09-13T05:41:01Z", event: "swarm.spawn", fleet: "f", host: "dev", by: "sysop", swarm: "long-0541", task: "x".repeat(80), ceiling: {}, pieces: [] }, + ], records: [] }], host: "dev", implicit: "anthony@dev" }), { host: "dev" }); + assert.match(long, new RegExp(`"${"x".repeat(56)} \\.\\.\\."`), "a long task is clipped at 60 with an ellipsis"); }); test("fold joins the herd roster: liveness for moshcode members, lost for a claimed pane the roster dropped, roster-only roots", () => { @@ -401,7 +495,8 @@ test("fold joins the herd roster: liveness for moshcode members, lost for a clai host: "dev", implicit: "anthony@dev", }); const swarm = live.fleets[0].nodes[0].swarms[1]; - assert.equal(swarm.members[1].state, "working", "the roster's state, since the member is claimed and has no end line"); + assert.equal(swarm.members[1].state, "working", "claimed and no end line; the herd's finer state rides beside it"); + assert.equal(swarm.members[1].herdState, "working"); assert.equal(swarm.members[1].live, true); assert.equal(swarm.members[1].lost, false); assert.equal(swarm.members[0].live, null, "a claude-code member is not in the herd roster; no liveness claimed"); @@ -409,7 +504,15 @@ test("fold joins the herd roster: liveness for moshcode members, lost for a clai assert.ok(rosterOnly, "a herd session with no record is a root of the implicit fleet"); assert.equal(rosterOnly.rosterOnly, true); assert.equal(rosterOnly.depth, 0); - assert.match(renderTree(live, { host: "dev" }), /shell-1 \/home\/anthony\/src moshcode\/shell idle \[roster\]/); + assert.match(renderTree(live, { host: "dev" }), /shell-1\s+\/home\/anthony\/src\s+moshcode\/shell\s+idle \[roster\]/); + + // A pane the herd still lists but that is no longer alive keeps its ledger state and is marked gone. + const dead = fold({ + fleets: [{ fleet: "anthony@dev", lines: EXAMPLE_LINES, records: EXAMPLE_RECORDS }], + roster: [{ name: "create-two-0541-2", engine: "moshcode/claude", state: "done", alive: false, approvals: "bypass" }], host: "dev", implicit: "anthony@dev", + }); + assert.equal(dead.fleets[0].nodes[0].swarms[1].members[1].state, "working", "the herd's state never replaces the ledger's in the state column"); + assert.match(renderTree(dead, { host: "dev" }), /create-two-0541-2\s+create bye\.sh bash\s+moshcode\/claude\s+working \[bypass\] \[gone\] owns bye\.sh/); const dropped = fold({ fleets: [{ fleet: "anthony@dev", lines: EXAMPLE_LINES, records: EXAMPLE_RECORDS }], roster: [], host: "dev", implicit: "anthony@dev" }); const gone = dropped.fleets[0].nodes[0].swarms[1].members[1]; @@ -417,6 +520,16 @@ test("fold joins the herd roster: liveness for moshcode members, lost for a clai assert.equal(gone.lost, true, "flagged so the tool writes the member.end"); assert.equal(dropped.fleets.length, 1, "an empty roster invents no implicit fleet entry"); assert.equal(dropped.fleets[0].nodes[0].swarms[1].members[0].lost, false, "an ended member is not lost"); + assert.equal(dropped.fleets[0].nodes[0].lost, false, "a claude-code member is never lost by the herd's roster: it cannot hold one"); + + // No roster at all (an unreadable manifest) says nothing about anyone; a pane on another host is not this roster's to lose. + const unread = fold({ fleets: [{ fleet: "anthony@dev", lines: EXAMPLE_LINES, records: EXAMPLE_RECORDS }], roster: null, host: "dev", implicit: "anthony@dev" }); + assert.equal(unread.fleets[0].nodes[0].swarms[1].members[1].lost, false); + const remote = fold({ fleets: [{ fleet: "anthony@dev", lines: EXAMPLE_LINES, records: EXAMPLE_RECORDS.map((r) => (r.member === "create-two-0541-2" ? { ...r, host: "netcup" } : r)) }], roster: [], host: "dev", implicit: "anthony@dev" }); + assert.equal(remote.fleets[0].nodes[0].swarms[1].members[1].lost, false); + assert.equal(herdHolds("moshcode/codex"), true); + assert.equal(herdHolds("tmux"), true); + assert.equal(herdHolds("claude-code"), false); const noFleet = fold({ fleets: [], roster: [{ name: "api", engine: "moshcode/claude", state: "idle", alive: true, approvals: "bypass" }], host: "dev", implicit: "anthony@dev" }); assert.equal(noFleet.fleets.length, 1); diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs index 57bc0ed4..8629ec41 100644 --- a/test/swarm.test.mjs +++ b/test/swarm.test.mjs @@ -155,7 +155,9 @@ test("a member's summary is its SUMMARY: section when it wrote one, else the tai assert.equal(summaryOf("first SUMMARY: no\nlater\nSummary: the last one counts"), "the last one counts"); assert.equal(summaryOf("x".repeat(600), { max: 500 }).length, 500); assert.equal(summaryOf(""), ""); - assert.equal(endStateOf({ outcome: "matched" }), "done"); + assert.equal(endStateOf({ outcome: "matched", state: "idle" }), "done"); + assert.equal(endStateOf({ outcome: "matched", state: "done" }), "done"); + assert.equal(endStateOf({ outcome: "matched", state: "blocked" }), "failed", "a pane asking a question did not finish its piece"); assert.equal(endStateOf({ outcome: "timeout" }), "timeout"); assert.equal(endStateOf({ outcome: "gone" }), "lost"); assert.equal(endStateOf({ outcome: "failed" }), "failed"); @@ -196,7 +198,7 @@ test("the synthesis carries every piece, its state, and its verdict", () => { { title: "b", state: "failed", artifact: "", verified: { refuted: true, reason: "no diff" } }, ] }); assert.match(p, /piece 1: a \(done\)/); - assert.match(p, /piece 2: b \(failed, review: REFUTED — no diff\)/); + assert.match(p, /piece 2: b \(failed, review: REFUTED: no diff\)/); assert.match(p, /A!/); assert.match(p, /\(no output captured\)/); }); @@ -264,12 +266,12 @@ function fakes({ plan, verdict = { refuted: false, reason: "fine" }, synthesis = // was written before the first member began. start: (name, opts) => { calls.start.push({ name, ...opts, seen: events() }); return failStart.includes(name) ? { ok: false, error: "tmux said no" } : { ok: true }; }, boot: async (name) => { calls.boot.push(name); return neverReady.includes(name) ? { outcome: "timeout", state: "working" } : { outcome: "matched", state: "idle" }; }, - prompt: async (name, text, { onSubmitted } = {}) => { + prompt: async (name, text, { onSubmitted, timeoutMs } = {}) => { running++; peak = Math.max(peak, running); onSubmitted?.({ at: now(), task: `t-${name}` }); await new Promise((r) => setTimeout(r, 5)); running--; - calls.prompt.push({ name, text }); + calls.prompt.push({ name, text, timeoutMs }); return { ok: true, task: `t-${name}`, outcome: "matched", state: "done", artifact: artifact(name), error: null }; }, kill: async (name) => { calls.kill.push(name); calls.killSeen.push(events()); }, @@ -316,7 +318,7 @@ test("a swarm plans, fans out, kills its sessions, and synthesises", () => inHom const synth = f.calls.ai.at(-1).prompt; assert.match(synth, new RegExp(`output of ${m(1)}`)); assert.match(synth, new RegExp(`output of ${m(2)}`)); - assert.equal(f.calls.ai.length, 2, "plan, synthesis — and no verifier unless asked"); + assert.equal(f.calls.ai.length, 2, "plan, synthesis, and no verifier unless asked"); })); test("the record of a swarm: spawn before the first start, one record and the four variables per member, start on submit, ends before the kills", () => inHome(async (home) => { @@ -355,7 +357,7 @@ test("the record of a swarm: spawn before the first start, one record and the fo assert.equal(record.session, m(1), "the pane name is the tmux target"); assert.equal(record.cwd, "/x"); assert.equal(record.approvals, "bypass", "truthful: the pane runs claude --dangerously-skip-permissions"); - assert.deepEqual(record.ceiling, { depth: 1, hosts: [host], fan_out: 4, until }); + assert.deepEqual(record.ceiling, { depth: 1, hosts: [host], fan_out: 4, until, approvals: "bypass" }, "a member of a swarm the sysop started by hand is its own root: its record carries its own approvals"); assert.equal(fs.statSync(openfleet.recordPath(fleet, m(1))).mode & 0o777, 0o600); // The four variables, plus the home, reach the pane. @@ -459,7 +461,8 @@ test("run by hand in the implicit fleet, the bypass flag is the sysop's own choi const result = await run({}, f); assert.equal(result.ok, true); assert.equal(ledger().filter((l) => l.event === "ceiling.refuse").length, 0); - assert.equal(openfleet.readMember(fleetOf(), m(1)).ceiling.approvals, undefined, "no fleet-level approvals in the implicit fleet"); + assert.equal(openfleet.readMember(fleetOf(), m(1)).ceiling.approvals, "bypass", "no fleet-level approvals in the implicit fleet: the root, which this member is, supplies its own"); + assert.equal(openfleet.context({ ...process.env, OPENFLEET_RECORD: openfleet.recordPath(fleetOf(), m(1)) }).ceiling.approvals, "bypass", "and every reader agrees"); })); test("an opened fleet's ceiling applies: native means the bypass flag is refused, and fan_out caps the plan", () => inHome(async () => { @@ -554,11 +557,49 @@ test("--verify attaches a verdict to every piece, shows it to the synthesis, and assert.deepEqual(result.results[0].verified, { refuted: true, reason: "claims a test it never ran" }); assert.equal(f.calls.ai.length, 3, "plan, verify, synthesis"); assert.match(f.calls.ai[1].prompt, new RegExp(`output of ${m(1)}`)); - assert.match(f.calls.ai.at(-1).prompt, /REFUTED — claims a test it never ran/); + assert.match(f.calls.ai.at(-1).prompt, /REFUTED: claims a test it never ran/); const end = ledger().find((l) => l.event === "swarm.end"); assert.deepEqual(end.verdict, [{ member: m(1), refuted: true, reason: "claims a test it never ran" }]); })); +test("a pane that ends its wait blocked, asking a question, is written failed, and its swarm with it", () => inHome(async () => { + const f = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] }); + const inner = f.deps.prompt; + f.deps.prompt = async (name, text, opts) => { + const r = await inner(name, text, opts); + return name === m(1) ? { ...r, state: "blocked" } : r; + }; + const result = await run({}, f); + assert.equal(result.ok, true, "the synthesis still ran"); + const ends = ledger().filter((l) => l.event === "member.end"); + assert.deepEqual(ends.map((l) => [l.member, l.state]), [[m(1), "failed"], [m(2), "done"]]); + assert.equal(ledger().find((l) => l.event === "swarm.end").state, "failed"); +})); + +test("an inherited until earlier than --timeout clamps each prompt's wait, so the swarm ends when the ceiling says", () => inHome(async () => { + openfleet.writeCurrent("team-20260913"); + const until = openfleet.iso(NOW + 30 * 1000); + openfleet.append("team-20260913", { event: "fleet.open", by: "sysop", sysop: "anthony@dev", ceiling: { approvals: "bypass", depth: 2, until } }); + const f = fakes({ plan: [{ title: "a", prompt: "a" }] }); + const result = await run({ timeoutMs: 60 * 1000 }, f); + assert.equal(result.ok, true); + const spawn = openfleet.readLedger("team-20260913").find((l) => l.event === "swarm.spawn"); + assert.equal(spawn.ceiling.until, undefined, "sixty seconds out is wider than the fleet's thirty: not a narrowing"); + assert.equal(openfleet.readMember("team-20260913", m(1)).ceiling.until, until); + const waited = f.calls.prompt[0].timeoutMs; + assert.ok(waited > 0 && waited <= 30 * 1000, `waited ${waited}ms, within the fleet's deadline`); + assert.ok(waited < 60 * 1000, "not the full --timeout"); +})); + +test("a one-piece swarm is the spawner's to end: swarm.end follows member.end, by moshcode, never treated as an engine's swarm of one", () => inHome(async () => { + insideMember({ member: "460a4502", depth: 0, approvals: "bypass" }); + const f = fakes({ plan: [{ title: "only", prompt: "do it" }] }); + await run({}, f); + const mine = ledger().filter((l) => l.swarm === SWARM || l.member === m(1)).map((l) => `${l.event}:${l.by}`); + assert.deepEqual(mine, ["swarm.spawn:460a4502", "member.start:460a4502", "member.end:460a4502", "swarm.end:460a4502"]); + assert.equal(ledger().find((l) => l.event === "swarm.end").swarm, SWARM, "the spawner's id, not -"); +})); + test("a session that already wrote its own member.start and member.end is left alone", () => inHome(async () => { // A claude pane with hooks claims its record and ends it itself; moshcode // writes neither line twice, and a real end is not followed by another. From 38c68e215d1320dc718df6caf5176e29009ac802 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 10:57:22 +0000 Subject: [PATCH 3/3] test(fleet): open asserts the injected implicit fleet's sysop, not this machine's The harness passes implicit: anthony@dev; comparing against the real user@host only passed on the dev box and failed on CI as runner@. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --- test/fleet-cli.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/fleet-cli.test.mjs b/test/fleet-cli.test.mjs index 21fbbc2f..0b420840 100644 --- a/test/fleet-cli.test.mjs +++ b/test/fleet-cli.test.mjs @@ -85,7 +85,8 @@ test("open mints -, writes fleet.open by sysop with the ceiling, const [line] = h.ledger("fleet-20260913"); assert.equal(line.event, "fleet.open"); assert.equal(line.by, "sysop"); - assert.equal(line.sysop, fleet.implicitFleet(h.opts.env)); + // The harness injects the implicit fleet, so the sysop is that, not this machine's user@host. + assert.equal(line.sysop, FLEET); assert.equal(line.host, "dev"); assert.deepEqual(line.ceiling, { approvals: "bypass", depth: 2, hosts: ["dev", "netcup"], until: at("07:42:00") }); assert.equal(fleet.currentFleet(h.opts.env), "fleet-20260913");