diff --git a/apps/logicsrc-web/src/app/openfleet/page.tsx b/apps/logicsrc-web/src/app/openfleet/page.tsx index e51d7ee..e295473 100644 --- a/apps/logicsrc-web/src/app/openfleet/page.tsx +++ b/apps/logicsrc-web/src/app/openfleet/page.tsx @@ -61,7 +61,7 @@ const STEPS: Array<[string, string]> = [ const VERBS: Array<[string, string, string]> = [ ["open", "sysop only", "mint a fleet, name the human, set the ceiling, write fleet.open"], ["cap", "sysop only", "set a fleet's ceiling or narrow a running swarm's; stop whatever is now above it"], - ["tree", "anyone", "the fleet as a tree, from the ledger and the records, with liveness and recordless members from the engine rosters it can read"], + ["tree", "anyone", "the fleet as a tree, from the ledger and the records, with liveness and recordless members from the engine rosters it can read; run by the sysop it also stops what is past its deadline or over its budget"], ["stop", "within reach", "end a member, a swarm, or a whole fleet as one unit; an agent reaches only what it spawned"], ["log", "anyone", "the ledger for a fleet, a swarm or a member: what happened, who did it, what it cost"] ]; diff --git a/docs/openfleet.md b/docs/openfleet.md index c212ad3..6a62269 100644 --- a/docs/openfleet.md +++ b/docs/openfleet.md @@ -150,7 +150,7 @@ The three lines that should have recorded the worked example's first piece, from | `swarm.spawn` | `swarm`, `parent_swarm`, `task`, `ceiling`, `pieces` | A spawner started a swarm: its id; `parent_swarm`, the swarm of the spawner's own record, absent when the spawner is a root member or the sysop; the task in the spawner's words; the keys it narrowed; and one piece per member it minted, `{ "member", "title", "owns" }`, with the paths that member owns as data. Written before the first member starts. `by` is the spawner: a member id, or `sysop` for a swarm the human started by hand. | | `member.start` | `member`, `session`, `swarm`, `parent`, `depth`, `engine`, `host`, `cwd`, `approvals`, `piece` | A member began, and its record is claimed. Written by the session itself with `by` its own member id, or by the starter, with `by` the starter, for an engine that cannot write it. `session` is the engine's own id when the record has none. The row a sysop tool draws in the tree; `approvals: bypass` is the mark it shows. | | `member.spend` | `member`, `amount`, `total` | A member spent something: `amount` since the last line and `total` so far, as ` ` or ` tokens`, in the engine's own numbers. Written by an engine that can count, at intervals or at the end. Summed per swarm and per fleet against `ceiling.budget`. | -| `member.end` | `member`, `state`, `summary`, `total`, `links` | A member finished. `state` is `done`, `failed`, `stopped`, `budget`, `timeout` or `lost`. `by` says who ended it: `sysop`, the spawner, or the member itself. `summary` is the member's closing summary when it wrote one; `total` its final spend; `links` the PRs and URLs it produced. `lost` is what a sysop tool writes for a member whose engine no longer lists it and that has no end line from any writer, with `by` `sysop` when the tool runs with no `OPENFLEET_MEMBER` and that member otherwise. A member has one end line that counts: the first written, except `lost`, which the engine's or the spawner's own `member.end` supersedes whenever it arrives. A session or tool that finds an end line for a member writes none, unless that line is `lost`, which the engine's or the spawner's own `member.end` may follow and supersede; `stop` on an ended member writes nothing. | +| `member.end` | `member`, `state`, `summary`, `total`, `links` | A member finished. `state` is `done`, `failed`, `stopped`, `budget`, `timeout` or `lost`. `by` says who ended it: `sysop`, the spawner, or the member itself. `summary` is the member's closing summary when it wrote one; `total` its final spend; `links` the PRs and URLs it produced. `lost` is what a sysop tool writes for a member whose engine's roster can hold it and no longer lists it, and that has no end line from any writer: a Claude Code background job, a moshcode pane. An interactive or `-p` Claude Code session is never in `claude agents`, so no roster marks it lost. `by` is `sysop` when the tool runs with no `OPENFLEET_MEMBER` and that member otherwise. A member has one end line that counts: the first written, except `lost`, which the engine's or the spawner's own `member.end` supersedes whenever it arrives. A session or tool that finds an end line for a member writes none, unless that line is `lost`, which the engine's or the spawner's own `member.end` may follow and supersede; `stop` on an ended member writes nothing. | | `swarm.end` | `swarm`, `state`, `summary`, `verdict` | A swarm ended as one unit: every member and every nested swarm under it has an end line at or before this one. `state` is `done` when every member ended `done`, else the first of `failed`, `stopped`, `budget`, `timeout` found among its members' end lines. `summary` is the spawner's synthesis, its closing summary of every member's summary, when it has one; `verdict` the verify result when it ran. One `swarm.end` per swarm ended, never two for the same swarm: a writer checks the ledger first and writes it only when none exists, and a synthesis that arrives after one goes into the spawner's own `member.end` `summary`. | | `ceiling.refuse` | `member`, `action`, `key`, `wanted`, `allowed` | An engine or tool refused something because it would exceed the ceiling: `action` is `start` or `spawn`; `key` names the ceiling key; `wanted` and `allowed` say the two values. `member` is the id the refused record names when one exists, else absent; `by` is the spawner: that record's `parent`, else the caller's `OPENFLEET_MEMBER`, else the refused record's own `member` when it carries `orphan`. This is how a sysop finds out what an agent tried. | @@ -162,7 +162,7 @@ Five verbs, over `$OPENFLEET_HOME`. Two are the sysop's alone. The test is the e |---|---|---| | `open` | sysop only | Creates a fleet: mints the id, records the sysop, sets the ceiling from flags (`--approvals native\|bypass --budget "20 USD" --depth 2 --fan-out 4 --hosts dev,netcup --until 2h`), writes `fleet.open`, writes the id to `current`, prints it. Refuses when `OPENFLEET_MEMBER` is set. | | `cap` | sysop only | Sets the whole ceiling of a fleet, or narrows a running swarm's, and writes `fleet.cap`. Members already above the new ceiling, a `bypass` member under a now-`native` ceiling, a member on a now-forbidden host, are stopped by the tool, each with `member.end` state `stopped`. Refuses when `OPENFLEET_MEMBER` is set. An agent narrows only at spawn time, in the `swarm.spawn` it writes. | -| `tree` | anyone | Renders one fleet, or every fleet on this host, as a tree: fleet, its swarms, each swarm's members and nested swarms, with state, engine, host, depth, spend against budget, and a mark on every member whose approvals is `bypass`. Built from the ledger and the records. When an engine's roster is readable, sessions it lists that have no record are drawn as root members of the implicit fleet and marked as coming from the roster, and a recorded member the roster no longer lists gets `member.end` state `lost`. An agent calls it on its own fleet to learn its siblings. | +| `tree` | anyone | Renders one fleet, or every fleet on this host, as a tree: fleet, its swarms, each swarm's members and nested swarms, with state, engine, host, depth, spend against budget, and a mark on every member whose approvals is `bypass`. Built from the ledger and the records. When an engine's roster is readable, sessions it lists that have no record are drawn as root members of the implicit fleet and marked as coming from the roster, and a recorded member the roster can hold and no longer lists (a background job, a pane; never an interactive session the roster does not list) gets `member.end` state `lost`. Run by the sysop, it enforces rule 6: a working member past its effective `until` is stopped through its engine and ends `timeout`, and a swarm or fleet whose summed `member.spend` has reached its budget has its members stopped, each ending `budget`, then its `swarm.end` when the swarm is complete. An agent calls it on its own fleet to learn its siblings; it stops nothing. | | `stop` | anyone, within reach | Ends a member, a swarm, or everything in a fleet (`--fleet`) as one unit. For a swarm: nested swarms first, each with its own `swarm.end`, then the target's members through each member's own engine, then the target's `swarm.end`. An agent may stop only a swarm it spawned or a member under such a swarm; `--fleet`, an ancestor, or a sibling's swarm refuses when `OPENFLEET_MEMBER` is set. | | `log` | anyone | Reads the ledger for a fleet, a swarm or a member: what happened, in order, who did it, what each member spent, how each ended, what was refused and why. `--since`, `--member`, `--swarm`, `--json`. | @@ -182,7 +182,7 @@ The reference sysop tool is `logicsrc fleet`, in `@logicsrc/openfleet` 0.1.0 (lo 10. The ledger is append-only, one JSON object per line, one file per fleet per host. Every line carries `at`, `event`, `fleet`, `host` and `by`; `by` is `sysop` or a member id; a tool never writes `sysop` for an action an agent took. 11. `stop` on a swarm ends nested swarms first, each with its own `swarm.end`, then the target's members through their own engines, then writes the target's `swarm.end`: one `swarm.end` per swarm ended, never two for the same swarm, so a writer checks the ledger first. 12. A member with no record is a root member of the implicit fleet `@` of the account that started it, never an error. The implicit fleet's ceiling is depth 1 and hosts that host, with no fleet-level `approvals`; each root member's subtree runs under the approvals that root was started with, read from the engine's roster: Claude Code's `respawnFlags`, moshcode's `args`. -13. A session started with no `OPENFLEET_RECORD` but with an engine's child marker (`CLAUDE_JOB_DIR`, `CLAUDE_CODE_CHILD_SESSION`, `MOSHCODE_HERD_NAME`) in the environment the engine was invoked with, before it sets its own session variables, was started by something that dropped its record. For a `--bg` job the launching `claude` process makes the test, not the daemon it starts. The engine writes a root record with `orphan: true`, then checks approvals before claiming it: a refusal writes `ceiling.refuse` with `member` and `by` the record's own `member` and leaves the record unclaimed, unless `current` names an opened fleet whose ceiling says `bypass`; otherwise it claims the record with `by` its own member id, and the tree marks the row. Under one account the environment test is a convention and the ledger is the audit; nothing in 0.1 stops a process from unsetting a variable. +13. A session started with no `OPENFLEET_RECORD` but with an engine's child marker (`CLAUDE_JOB_DIR`, `CLAUDE_CODE_CHILD_SESSION`, `MOSHCODE_HERD_NAME`) in the environment the engine was invoked with, before it sets its own session variables, was started by something that dropped its record. For a `--bg` job the launching `claude` process makes the test, not the daemon it starts. In 0.1 no launcher makes that test yet: a background job dispatched with no launcher record is a clean root of the implicit fleet. The engine writes a root record with `orphan: true`, then checks approvals before claiming it: a refusal writes `ceiling.refuse` with `member` and `by` the record's own `member` and leaves the record unclaimed, unless `current` names an opened fleet whose ceiling says `bypass`; otherwise it claims the record with `by` its own member id, and the tree marks the row. Under one account the environment test is a convention and the ledger is the audit; nothing in 0.1 stops a process from unsetting a variable. 14. A sysop tool renders every recorded member from records and the ledger. Members with no record exist only in an engine's roster, and the tool reads the rosters it can (`claude agents --json`, `~/.moshcode/herd/sessions.json`) to draw them and to add liveness. No roster is required for a recorded member. 15. A record and a ledger never hold a credential. Unknown keys are kept. @@ -206,7 +206,7 @@ Three ship: `logicsrc fleet` in `@logicsrc/openfleet` 0.1.0 (logicsrc CLI 0.3.0) **moshcode.** From 0.99.0, `moshcode swarm` writes the record and the ledger, and `moshcode fleet` is the sysop tool for its engine. What that means, in `moshcode swarm`: mint the swarm id before the plan call, and run the planner with no `OPENFLEET_SWARM`, since its `swarm.spawn` does not exist until the plan returns; extend the planner's reply to `[{ "title", "prompt", "files" }]` and store `files` as `piece.owns`, so "do not touch bye.sh" becomes data moshcode can check instead of prose it never parses; write `swarm.spawn` with one piece per pane, member ids `-`, and the narrowing from `--agents` (`fan_out`) and `--timeout` (`until`); name each pane after its member id and write one unclaimed record per pane with `session` the pane's tmux target; add the four variables to the pane's environment line beside `MOSHCODE_HERD_NAME` and `MOSHCODE_HERD_DIR`, and keep them when deleting `ANTHROPIC_API_KEY` and `CLAUDE_CODE_SESSION_ID`. A `claude` pane claims its own record. For codex, deepseek and kimi panes moshcode writes `member.start` from the herd ledger's `submit` event, since nothing else in the pane writes a record. Record `approvals: bypass` truthfully: today `sessions.json` says `agent: false` while the pane runs `claude --dangerously-skip-permissions`. Refuse that flag with `ceiling.refuse` unless the ceiling says `bypass`. At the end, write `member.end` from the herd `end` event for every pane whose session has not written its own by then, then `swarm.end` with the synthesis as `summary` and the `--verify` result as `verdict`, then the default kill; `--keep` leaves members running and writes neither `member.end` nor `swarm.end`. When moshcode itself runs inside a member, the swarm's parent is that member. `moshcode fleet open|cap|tree|stop|log` is the sysop tool for this engine, with `herd ps` grouped by fleet and swarm. -**logicsrc.** `logicsrc fleet open|cap|tree|stop|log`, the engine-neutral sysop tool that folds any `$OPENFLEET_HOME` into one tree and stops a member through the engine its record names: `claude stop` for `claude-code`, `moshcode herd kill` for `moshcode/*`, `tmux kill-pane` for `tmux`, a signal to the pid for `claude-p`. Ships in `@logicsrc/openfleet` 0.1.0 with the logicsrc CLI 0.3.0. `tree` reads `claude agents --json --all` and `~/.moshcode/herd/sessions.json` for liveness and for members with no record, and writes `member.end` state `lost` for a recorded member its engine no longer lists. `logicsrc fleet hooks install|remove|status` and `logicsrc fleet hook ` are the Claude Code side above. Every verb takes `--json`. +**logicsrc.** `logicsrc fleet open|cap|tree|stop|log`, the engine-neutral sysop tool that folds any `$OPENFLEET_HOME` into one tree and stops a member through the engine its record names: `claude stop` for `claude-code`, `moshcode herd kill` for `moshcode/*`, `tmux kill-pane` for `tmux`, a signal to the pid for `claude-p`. Ships in `@logicsrc/openfleet` 0.1.0 with the logicsrc CLI 0.3.0. `tree` reads `claude agents --json --all` and `~/.moshcode/herd/sessions.json` for liveness and for members with no record, and writes `member.end` state `lost` for a recorded background job or pane its engine's roster can hold and no longer lists; an interactive or `-p` `claude` session, which `claude agents` never lists, is never marked lost. Run by the sysop, `tree` also enforces rule 6: a member past its effective `until` is stopped through its engine and ends `timeout`, a swarm or fleet whose summed `member.spend` has reached its budget has its members stopped, each ending `budget`, and each swarm touched gets its `swarm.end` once it is complete. `stop` on a claude-code member calls `claude stop` with the job id: the member id of a background job, else the first eight characters of the record's session when that is a session UUID; an interactive session with no job id cannot be stopped by the tool, which says so. Every `member.start`, `member.end` and `swarm.end` the tool or the hooks write takes a once-marker first, an exclusive create of `$OPENFLEET_HOME/fleets//marks/.` (`member.end..lost` for a `lost` line, so a real end can still follow it and take the plain one); a writer that finds the marker taken writes nothing and reports "already". moshcode uses the same paths, so the two writers never double a line. `logicsrc fleet hooks install|remove|status` and `logicsrc fleet hook ` are the Claude Code side above. Every verb takes `--json`. ## What is deliberately absent diff --git a/packages/openfleet/README.md b/packages/openfleet/README.md new file mode 100644 index 0000000..2ec0a6b --- /dev/null +++ b/packages/openfleet/README.md @@ -0,0 +1,116 @@ +# @logicsrc/openfleet + +Reference implementation of [OpenFleet](https://logicsrc.com/docs/openfleet), +the record an agent session carries about where it sits: which human answers +for it, who spawned it, for what task, at what depth, and under what ceiling. +A fleet is every agent session one human, its sysop, is answerable for. A +swarm is the set of sessions one spawner starts to do one task. + +This package holds the record, the ledger, the ceiling rules, claiming and +deriving, the folded tree, the five sysop verbs, and the Claude Code hooks. +moshcode writes the same files from `moshcode swarm` and reads them with +`moshcode fleet`; one tree shows both engines. + +## Install + +```bash +npm install -g @logicsrc/cli +logicsrc fleet --help +``` + +The verbs live in this package and the umbrella CLI wraps them as +`logicsrc fleet`. To use the library directly: + +```bash +npm install @logicsrc/openfleet +``` + +## The files + +Everything lives under `$OPENFLEET_HOME`, default `~/.openfleet`: + +| Path | What | +| --- | --- | +| `fleets//members/.json` | One record per member, written before it starts, never changed after `member.start`. | +| `fleets//ledger.jsonl` | The append-only ledger for this host. `ledger..jsonl` copies from other hosts are merged by `at`. | +| `fleets//marks/.` | Once-markers for `member.start`, `member.end` and `swarm.end`, so two writers never double a line. | +| `current` | The fleet the account's next root member joins. Absent means the implicit `@`. | +| `sessions/.json` | The Claude Code hooks' own lookup, not part of the spec. | + +Files are 0600 and directories 0700. + +## The five verbs + +```bash +# Sysop only: mint a fleet, set its whole ceiling, write fleet.open and current. +logicsrc fleet open team --approvals bypass --budget "20 USD" --depth 2 --fan-out 4 --hosts dev,netcup --until 2h + +# Sysop only: set a fleet's whole ceiling, or narrow a running swarm's. Members now above it are stopped. +logicsrc fleet cap team-20260913 --approvals native +logicsrc fleet cap create-two-0541 --fan-out 2 + +# Anyone: the tree, from the ledger, the records and the engine rosters. Run by the sysop it also +# stops what is past its deadline or over its budget (rule 6) and marks lost what its engine no longer lists. +logicsrc fleet tree +logicsrc fleet tree anthony@dev --json + +# Anyone, within reach: end a member, a swarm (nested swarms first), or a whole fleet through each member's engine. +logicsrc fleet stop create-two-0541 +logicsrc fleet stop team-20260913 --fleet + +# Anyone: the ledger, in order, with who did it. +logicsrc fleet log --swarm create-two-0541 +logicsrc fleet log --since 2h --json +``` + +Every verb takes `--json`. `open` and `cap` refuse with exit 4 when the +process carries `OPENFLEET_MEMBER`: that process is an agent. `stop` refuses +outside the caller's own subtree the same way. An engine that will not end a +member leaves it without an end line and the verb exits 3. + +`stop` goes through the member's own engine: `claude stop ` for +`claude-code`, `moshcode herd kill` for `moshcode/*`, `tmux kill-pane` for +`tmux`, a signal to the pid for `claude-p`. + +## Claude Code hooks + +```bash +logicsrc fleet hooks install # merges five entries into ~/.claude/settings.json, never clobbers +logicsrc fleet hooks status +logicsrc fleet hooks remove # takes out only ours +``` + +With the hooks installed every Claude Code session becomes a recorded member: +SessionStart claims the record `OPENFLEET_RECORD` names, derives a child under +a claimed one, or writes a root record; UserPromptSubmit checks the ceiling +with the permission mode the engine reports and writes `member.start`, or +refuses the first prompt with exit 2; PreToolUse denies an Edit or Write +outside `piece.owns`; Stop and SessionEnd write `member.end`. A hook never +fails the engine. `logicsrc fleet hook ` is the entry point the +settings file calls. + +## Environment + +| Variable | Meaning | +| --- | --- | +| `OPENFLEET_HOME` | The root directory. Default `~/.openfleet`. | +| `OPENFLEET_RECORD` | The absolute path of this member's record. | +| `OPENFLEET_FLEET` | A copy of the record's `fleet`; in the sysop's shell, the fleet new roots join. | +| `OPENFLEET_MEMBER` | A copy of the record's `member`. Present means this process is an agent. | +| `OPENFLEET_SWARM` | A copy of the record's `swarm`, when it carries one. | + +## Library + +```js +import { context, claimOrDerive, startMember, endMember, fold, renderTree, readLedger } from "@logicsrc/openfleet"; +import { registerOpenFleetCommands } from "@logicsrc/openfleet/commands"; +``` + +`context(env)` answers which fleet and which member a process is. `claimOrDerive` +is the engine-side rule from the spec's "Claiming and deriving". `fold` builds +the tree `tree` renders. `registerOpenFleetCommands(command, deps)` mounts the +verbs on a commander command with every world-touching dependency injectable. + +## License + +MIT. The specification text is CC BY 4.0. diff --git a/packages/openfleet/src/ceiling.test.ts b/packages/openfleet/src/ceiling.test.ts index f8d55b4..a8dfdf4 100644 --- a/packages/openfleet/src/ceiling.test.ts +++ b/packages/openfleet/src/ceiling.test.ts @@ -84,15 +84,24 @@ describe("the fleet's ceiling and the path down", () => { expect(fleetCeiling([], "f", implicit)).toEqual(implicit); expect(isImplicitFleet([], "f")).toBe(true); const opened = [line({ event: "fleet.open", fleet: "f", ceiling: { approvals: "bypass", depth: 2 } })]; - expect(fleetCeiling(opened, "f", implicit)).toEqual({ approvals: "bypass", depth: 2 }); + // hosts absent means the host the line was written on. + expect(fleetCeiling(opened, "f", implicit)).toEqual({ approvals: "bypass", depth: 2, hosts: ["dev"] }); expect(isImplicitFleet(opened, "f")).toBe(false); const capped = [ ...opened, line({ event: "fleet.cap", target: "f", ceiling: { approvals: "native", depth: 1 } }), line({ event: "fleet.cap", target: "some-swarm", ceiling: { depth: 0 } }), - line({ event: "fleet.cap", target: "f", ceiling: { approvals: "bypass", depth: 3 } }), + line({ event: "fleet.cap", target: "f", ceiling: { approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] } }), ]; - expect(fleetCeiling(capped, "f", implicit)).toEqual({ approvals: "bypass", depth: 3 }); + expect(fleetCeiling(capped, "f", implicit)).toEqual({ approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] }); + }); + + it("a fleet opened with an empty ceiling admits members on its own host only", () => { + const opened = [line({ event: "fleet.open", fleet: "f", host: "dev", ceiling: {} })]; + const ceiling = fleetCeiling(opened, "f", implicit); + expect(ceiling).toEqual({ hosts: ["dev"] }); + expect(checkCeiling({ hosts: ["dev"] }, ceiling)).toBeNull(); + expect(checkCeiling({ hosts: ["netcup"] }, ceiling)).toEqual({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] }); }); it("follows parent_swarm to the top and merges spawn narrowings first, then swarm caps last", () => { diff --git a/packages/openfleet/src/ceiling.ts b/packages/openfleet/src/ceiling.ts index 2d85177..4aaf73c 100644 --- a/packages/openfleet/src/ceiling.ts +++ b/packages/openfleet/src/ceiling.ts @@ -108,14 +108,18 @@ export function mergeCeiling(base: Ceiling, narrowing: Ceiling | undefined): Cei /** * A fleet's whole ceiling: the latest `fleet.cap` whose target is the fleet, - * else `fleet.open`, else the implicit fleet's. + * else `fleet.open`, else the implicit fleet's. A line that names no `hosts` + * means the host it was written on (the ceiling table), so the two reference + * readers admit the same members whichever tool opened the fleet. */ export function fleetCeiling(lines: LedgerLine[], fleet: string, implicit: Ceiling): Ceiling { const caps = findEvents(lines, "fleet.cap", { target: fleet }); - if (caps.length) return { ...(caps[caps.length - 1].ceiling ?? {}) }; const opens = findEvents(lines, "fleet.open", { fleet }); - if (opens.length) return { ...(opens[opens.length - 1].ceiling ?? {}) }; - return { ...implicit }; + const line = caps.length ? caps[caps.length - 1] : opens.length ? opens[opens.length - 1] : null; + if (!line) return { ...implicit }; + const ceiling: Ceiling = { ...(line.ceiling ?? {}) }; + if (ceiling.hosts === undefined && typeof line.host === "string" && line.host !== "") ceiling.hosts = [line.host]; + return ceiling; } /** True when the ledger holds no `fleet.open` for this fleet: it is the implicit one. */ diff --git a/packages/openfleet/src/commands.test.ts b/packages/openfleet/src/commands.test.ts index 1636598..a83a643 100644 --- a/packages/openfleet/src/commands.test.ts +++ b/packages/openfleet/src/commands.test.ts @@ -139,6 +139,20 @@ describe("cap", () => { const agent = harness(home, {}, { OPENFLEET_MEMBER: "460a4502" }); expect(await agent.run("cap", "create-two-0541", "--depth", "1")).toBe(EXIT.REFUSED); }); + + it("refuses a cap that would widen a swarm before writing anything: a cap on a swarm only narrows", async () => { + const h = harness(home); + expect(await h.run("cap", "create-two-0541", "--fan-out", "8")).toBe(EXIT.INVALID); + expect(h.err[0]).toBe("cap on swarm create-two-0541 never widens: fan_out 8 is not within 4"); + expect(await h.run("cap", "create-two-0541", "--depth", "2")).toBe(EXIT.INVALID); + expect(await h.run("cap", "create-two-0541", "--until", "2026-09-13T07:00:00Z")).toBe(EXIT.INVALID); + expect(await h.run("cap", "create-two-0541", "--hosts", "dev,netcup")).toBe(EXIT.INVALID); + expect(findEvents(readLedger(home, FLEET), "fleet.cap")).toEqual([]); + expect(h.execs).toEqual([]); + // Equal or narrower lands. + expect(await h.run("cap", "create-two-0541", "--fan-out", "2", "--until", "2026-09-13T06:05:00Z")).toBe(0); + expect(findEvents(readLedger(home, FLEET), "fleet.cap")[0]).toMatchObject({ target: "create-two-0541", ceiling: { fan_out: 2, until: "2026-09-13T06:05:00Z" } }); + }); }); describe("tree", () => { @@ -174,6 +188,77 @@ describe("tree", () => { const quiet = harness(home, { rosters: { claude: async () => { throw new Error("should not be read"); } } }); expect(await quiet.run("tree", "--no-roster")).toBe(0); }); + + it("never marks an interactive claude session lost: the roster cannot hold it", async () => { + const interactive = "68aca9c1-1111-4222-8333-444455556666"; + append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "member.start", by: interactive, member: interactive, session: interactive, depth: 0, engine: "claude-code", cwd: "/x", approvals: "native" }, { host: "dev" }); + const h = harness(home, { rosters: { claude: async () => [] } }); + expect(await h.run("tree")).toBe(0); + expect(findEvents(readLedger(home, FLEET), "member.end", { member: interactive })).toEqual([]); + expect(h.out[0]).toMatch(new RegExp(`${interactive} +claude-code +working$`, "m")); + // The background job beside it, same roster, is lost. + expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ state: "lost" }); + }); + + it("enforces a deadline: a working member past its effective until is stopped through its engine and ends timeout, then its swarm ends", async () => { + // The worked example's swarm runs until 06:11:01Z; the clock reads 06:30. + const late = harness(home, { now: () => new Date("2026-09-13T06:30:00Z") }); + expect(await late.run("tree")).toBe(0); + expect(late.execs).toEqual([["moshcode", "herd", "kill", "create-two-0541-2"]]); + const lines = readLedger(home, FLEET); + expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "timeout", at: "2026-09-13T06:30:00Z" }); + expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })[0]).toMatchObject({ by: "sysop", state: "timeout" }); + // The root has no deadline and is left alone. + expect(findEvents(lines, "member.end", { member: "460a4502" })).toEqual([]); + expect(late.out[0]).toMatch(/create-two-0541-2 +create bye.sh bash +moshcode\/claude +timeout/); + expect(late.out[0]).toMatch(/swarm create-two-0541 +"create two \.\.\." +2\/4 members +timeout/); + expect(late.out.slice(1)).toEqual(["stopped create-two-0541-2 moshcode/claude timeout", "ended swarm create-two-0541 timeout"]); + // A second run finds everything ended and writes nothing more. + const again = harness(home, { now: () => new Date("2026-09-13T06:31:00Z") }); + expect(await again.run("tree", "--json")).toBe(0); + expect(again.execs).toEqual([]); + expect(JSON.parse(again.out[0]).enforced).toEqual({ members: [], swarms: [] }); + expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: "create-two-0541" }).length).toBe(1); + }); + + it("leaves enforcement to the sysop: an agent's tree stops nothing", async () => { + const agent = harness(home, { now: () => new Date("2026-09-13T06:30:00Z") }, { OPENFLEET_MEMBER: "460a4502" }); + expect(await agent.run("tree")).toBe(0); + expect(agent.execs).toEqual([]); + expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })).toEqual([]); + }); + + it("enforces a budget: a swarm whose spend has reached its budget has its members stopped with budget, siblings outside it untouched", async () => { + const fleet = "team-20260913"; + append(home, fleet, { at: "2026-09-13T05:00:00Z", event: "fleet.open", by: "sysop", fleet, sysop: "anthony@dev", ceiling: { approvals: "bypass", depth: 2, hosts: ["dev"], budget: "20 USD" } }, { host: "dev" }); + append(home, fleet, { at: "2026-09-13T05:01:00Z", event: "swarm.spawn", by: "sysop", swarm: "hand-0501", task: "by hand", ceiling: { budget: "5 USD" }, pieces: [{ member: "hand-0501-1" }, { member: "hand-0501-2" }] }, { host: "dev" }); + append(home, fleet, { at: "2026-09-13T05:02:00Z", event: "member.start", by: "hand-0501-1", member: "hand-0501-1", session: "hand-0501-1", swarm: "hand-0501", depth: 0, engine: "moshcode/codex", approvals: "native" }, { host: "dev" }); + append(home, fleet, { at: "2026-09-13T05:02:01Z", event: "member.start", by: "hand-0501-2", member: "hand-0501-2", session: "hand-0501-2", swarm: "hand-0501", depth: 0, engine: "moshcode/kimi", approvals: "native" }, { host: "dev" }); + append(home, fleet, { at: "2026-09-13T05:03:00Z", event: "member.start", by: "aaaa0001", member: "aaaa0001", session: "aaaa0001", depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" }); + append(home, fleet, { at: "2026-09-13T05:05:00Z", event: "member.spend", by: "hand-0501-1", member: "hand-0501-1", amount: "3 USD", total: "3 USD" }, { host: "dev" }); + append(home, fleet, { at: "2026-09-13T05:06:00Z", event: "member.spend", by: "hand-0501-2", member: "hand-0501-2", amount: "2 USD", total: "2 USD" }, { host: "dev" }); + const h = harness(home); + expect(await h.run("tree", fleet)).toBe(0); + expect(h.execs).toEqual([ + ["moshcode", "herd", "kill", "hand-0501-1"], + ["moshcode", "herd", "kill", "hand-0501-2"], + ]); + const lines = readLedger(home, fleet); + expect(findEvents(lines, "member.end").map((line) => [line.member, line.state, line.by])).toEqual([ + ["hand-0501-1", "budget", "sysop"], + ["hand-0501-2", "budget", "sysop"], + ]); + expect(findEvents(lines, "swarm.end", { swarm: "hand-0501" })[0]).toMatchObject({ state: "budget", by: "sysop" }); + expect(h.out[0]).toMatch(/swarm hand-0501 +"by hand" +2 members +budget {2}5\/5 USD/); + expect(h.out[0]).toMatch(/aaaa0001 +claude-code +working$/m); + // The fleet's own budget: raise the spend past 20 USD and the root goes too. + append(home, fleet, { at: "2026-09-13T06:00:01Z", event: "member.spend", by: "aaaa0001", member: "aaaa0001", amount: "15 USD", total: "15 USD" }, { host: "dev" }); + const over = harness(home, { now: () => new Date("2026-09-13T06:01:00Z") }); + expect(await over.run("tree", fleet, "--json")).toBe(0); + expect(over.execs).toEqual([["claude", "stop", "aaaa0001"]]); + expect(findEvents(readLedger(home, fleet), "member.end", { member: "aaaa0001" })[0]).toMatchObject({ state: "budget" }); + expect(JSON.parse(over.out[0]).enforced.members).toEqual([{ member: "aaaa0001", engine: "claude-code", stopped: true, state: "budget" }]); + }); }); describe("stop", () => { @@ -236,6 +321,45 @@ describe("stop", () => { expect(await h.run("stop", "nobody")).toBe(EXIT.NOT_FOUND); }); + it("stops a claude-code member by its job id: the member of a background job, else the first eight characters of a session UUID, and says when there is none", async () => { + const uuid = "68aca9c1-1111-4222-8333-444455556666"; + append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "member.start", by: "piece-x", member: "piece-x", session: uuid, depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" }); + append(home, FLEET, { at: "2026-09-13T05:51:00Z", event: "member.start", by: uuid, member: uuid, session: uuid, depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" }); + const h = harness(home); + expect(await h.run("stop", "piece-x")).toBe(0); + expect(h.execs).toEqual([["claude", "stop", "68aca9c1"]]); + const interactive = harness(home); + expect(await interactive.run("stop", uuid, "--json")).toBe(EXIT.NOT_FOUND); + expect(interactive.execs).toEqual([]); + expect(JSON.parse(interactive.out[0]).members[0]).toMatchObject({ member: uuid, stopped: false, error: expect.stringContaining("claude stop cannot end it") }); + expect(findEvents(readLedger(home, FLEET), "member.end", { member: uuid })).toEqual([]); + }); + + it("writes no swarm.end while a member's engine would not stop, or a nested swarm has no swarm.end, and exits non-zero", async () => { + append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "swarm.spawn", by: "create-two-0541-2", swarm: "nested-0550", parent_swarm: "create-two-0541", task: "nested", ceiling: {}, pieces: [{ member: "nested-0550-1" }] }, { host: "dev" }); + append(home, FLEET, { at: "2026-09-13T05:50:02Z", event: "member.start", by: "nested-0550-1", member: "nested-0550-1", session: "%7", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "tmux", approvals: "native" }, { host: "dev" }); + // tmux says no; moshcode says yes. + const h = harness(home, { exec: async (file) => (file === "tmux" ? { code: 1, stdout: "", stderr: "can't find pane: %7" } : { code: 0, stdout: "", stderr: "" }) }); + expect(await h.run("stop", "create-two-0541")).toBe(EXIT.NOT_FOUND); + const lines = readLedger(home, FLEET); + expect(findEvents(lines, "member.end", { member: "nested-0550-1" })).toEqual([]); + expect(findEvents(lines, "swarm.end", { swarm: "nested-0550" })).toEqual([]); + expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })).toEqual([]); + expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ state: "stopped" }); + expect(h.out).toEqual([ + "skipped nested-0550-1 tmux (tmux kill-pane -t %7 exited 1: can't find pane: %7)", + "left swarm nested-0550 open (no end line yet for nested-0550-1)", + "skipped create-two-0541-1 claude-code done (already ended)", + "stopped create-two-0541-2 moshcode/claude stopped", + "left swarm create-two-0541 open (no swarm.end yet for nested nested-0550)", + ]); + // Once the pane can be ended, the nested swarm and then the target end, each once. + const later = harness(home); + expect(await later.run("stop", "create-two-0541", "--json")).toBe(0); + expect(later.execs).toEqual([["tmux", "-L", "moshcode", "kill-pane", "-t", "%7"]]); + expect(JSON.parse(later.out[0]).swarms).toEqual([{ swarm: "nested-0550", state: "stopped", ended: true }, { swarm: "create-two-0541", state: "stopped", ended: true }]); + }); + it("lets an agent stop only the swarm it spawned and what sits under it", async () => { const spawner = harness(home, {}, { OPENFLEET_MEMBER: "460a4502" }); expect(await spawner.run("stop", "create-two-0541-2")).toBe(0); diff --git a/packages/openfleet/src/commands.ts b/packages/openfleet/src/commands.ts index a26331a..1aa460f 100644 --- a/packages/openfleet/src/commands.ts +++ b/packages/openfleet/src/commands.ts @@ -11,14 +11,15 @@ import { existsSync } from "node:fs"; import type { Command } from "commander"; -import { isNarrower, parseBudget, parseUntil } from "./ceiling.js"; -import { endMember, memberCeiling } from "./context.js"; +import { effectiveCeiling, fleetCeiling, isImplicitFleet, isNarrower, parseBudget, parseUntil, rootApprovals, swarmChain } from "./ceiling.js"; +import { endMember, memberCeiling, rootOf } from "./context.js"; import { flattenMembers, flattenSwarms, fold, renderTree } from "./fold.js"; import { hooksStatus, installHooks, removeHooks, settingsFile } from "./hooks-install.js"; import { realHookIo, runHook, type HookIo } from "./hooks.js"; -import { defaultRosters, realExec, type Exec } from "./rosters.js"; +import { claudeJobId, defaultRosters, realExec, type Exec } from "./rosters.js"; import { append, + appendOnce, claimedBy, endOf, findEvents, @@ -27,6 +28,7 @@ import { implicitFleet, isoNow, listFleets, + markName, readLedger, readRecords, spawnOf, @@ -37,10 +39,11 @@ import { type ImplicitFleet, } from "./store.js"; import { slug } from "./swarm.js"; -import type { Approvals, Ceiling, EndState, FleetRecord, LedgerLine, Rosters, Tree } from "./types.js"; +import type { Approvals, Ceiling, EndState, FleetRecord, LedgerLine, MemberNode, Rosters, SwarmNode, Tree } from "./types.js"; +import { CEILING_KEYS, OPENFLEET_VERSION } from "./types.js"; -/** Exit codes: 0 ok, 1 usage, 2 invalid input, 3 not found, 4 refused (the human-only verbs and stop outside reach). */ +/** Exit codes: 0 ok, 1 usage, 2 invalid input, 3 not found or an engine that would not end a member, 4 refused (the human-only verbs and stop outside reach). */ export const EXIT = { OK: 0, USAGE: 1, INVALID: 2, NOT_FOUND: 3, REFUSED: 4 } as const; export interface Deps { @@ -210,6 +213,8 @@ interface StopRow { stopped: boolean; state?: string; note?: string; + /** The engine would not end the member: no end line was written and the verb exits non-zero. */ + error?: string; } interface SwarmRow { @@ -240,17 +245,15 @@ function report(rows: Row[]): { members: Omit[]; swarms: Omit & { member: string }): Promise { const engine = record.engine; const handle = record.session ?? record.member; if (engine === "claude-code") { - const result = await deps.exec("claude", ["stop", claudeHandle(handle)]); - return result.code === 0 ? null : `claude stop ${claudeHandle(handle)} exited ${result.code}: ${result.stderr.trim() || result.stdout.trim()}`; + // `claude stop` takes a job id: the member of a background job, else the first eight characters of a session UUID. + const jobId = claudeJobId(record.member, record.session); + if (!jobId) return `member ${record.member} is an interactive claude session with no job id: claude stop cannot end it, close the session instead`; + const result = await deps.exec("claude", ["stop", jobId]); + return result.code === 0 ? null : `claude stop ${jobId} exited ${result.code}: ${result.stderr.trim() || result.stdout.trim()}`; } if (typeof engine === "string" && engine.startsWith("moshcode/")) { const result = await deps.exec("moshcode", ["herd", "kill", handle]); @@ -273,16 +276,38 @@ async function stopThroughEngine(deps: Deps, record: Partial & { me return engine ? `no way to stop engine ${engine}` : `member ${record.member} names no engine`; } -/** A member as the stop path sees it: its record when there is one, else what its member.start said. */ -function memberFacts(home: string, fleet: string, lines: LedgerLine[], member: string): (Partial & { member: string }) | null { - const record = readRecords(home, fleet).get(member); +/** + * A member as the stop and ceiling paths see it: its record when there is + * one, else a record shaped from what its `member.start` said (it may live on + * another host, whose ledger was copied in without its record files). + */ +function memberFacts(home: string, fleet: string, lines: LedgerLine[], member: string, records?: Map): FleetRecord | null { + const record = (records ?? readRecords(home, fleet)).get(member); if (record) return record; const start = claimedBy(lines, member); if (!start) return null; - return { member, engine: start.engine, session: start.session, swarm: start.swarm, parent: start.parent, host: start.host }; + return { + openfleet: OPENFLEET_VERSION, + fleet, + sysop: "", + member, + ...(start.parent ? { parent: start.parent } : {}), + ...(start.swarm ? { swarm: start.swarm } : {}), + ...(start.depth !== undefined ? { depth: start.depth } : {}), + ...(start.engine ? { engine: start.engine } : {}), + ...(start.session ? { session: start.session } : {}), + ...(start.host ? { host: start.host } : {}), + ...(start.approvals ? { approvals: start.approvals } : {}), + }; } -async function stopMember(ctx: Ctx, fleet: string, member: string, by: string, rows: Row[]): Promise { +/** + * End one member through its engine, then write its `member.end` with the + * state given (`stopped` for the verbs, `timeout` or `budget` for rule 6). An + * engine that says no leaves the member without an end line and the row + * carries the error, so the verb can exit non-zero. + */ +async function stopMember(ctx: Ctx, fleet: string, member: string, by: string, rows: Row[], state: EndState = "stopped"): Promise { const lines = readLedger(ctx.home, fleet); const facts = memberFacts(ctx.home, fleet, lines, member); if (!facts) { @@ -300,11 +325,21 @@ async function stopMember(ctx: Ctx, fleet: string, member: string, by: string, r } const problem = await stopThroughEngine(ctx.deps, facts); if (problem) { - rows.push({ kind: "member", member, engine: facts.engine, stopped: false, note: problem }); + rows.push({ kind: "member", member, engine: facts.engine, stopped: false, error: problem }); return; } - endMember(ctx.home, fleet, member, { state: "stopped", by, now: ctx.deps.now(), host: ctx.host }, facts.swarm); - rows.push({ kind: "member", member, engine: facts.engine, stopped: true, state: "stopped" }); + const result = endMember(ctx.home, fleet, member, { state, by, now: ctx.deps.now(), host: ctx.host }, facts.swarm); + if (!result.ended) { + // The engine ended it and wrote its own line between our check and our write; that line counts. + rows.push({ kind: "member", member, engine: facts.engine, stopped: true, state: String(result.already?.state ?? state), note: "ended meanwhile" }); + return; + } + rows.push({ kind: "member", member, engine: facts.engine, stopped: true, state }); +} + +/** An engine that would not end a member fails the verb: the row says why and the exit code says so. */ +function exitOnEngineFailure(rows: Row[]): void { + if (rows.some((row) => row.kind === "member" && row.error !== undefined)) process.exitCode = EXIT.NOT_FOUND; } /** Members of a swarm: records and starts that name it, plus pieces its spawn minted. */ @@ -316,18 +351,37 @@ function membersOfSwarm(home: string, fleet: string, lines: LedgerLine[], swarm: return [...out]; } -/** Rule 11: nested swarms first, then the members through their engines, then one swarm.end. */ -async function stopSwarm(ctx: Ctx, fleet: string, swarm: string, by: string, rows: Row[]): Promise { - let lines = readLedger(ctx.home, fleet); - const nested = findEvents(lines, "swarm.spawn", { parent_swarm: swarm }).map((line) => String(line.swarm)); - for (const child of nested) await stopSwarm(ctx, fleet, child, by, rows); - const members = membersOfSwarm(ctx.home, fleet, lines, swarm); - for (const member of members) await stopMember(ctx, fleet, member, by, rows); - lines = readLedger(ctx.home, fleet); - if (swarmEndOf(lines, swarm)) { - rows.push({ kind: "swarm", swarm, state: String(swarmEndOf(lines, swarm)?.state), ended: false, note: "already ended" }); +/** Members of a swarm and of every swarm nested under it. */ +function membersUnderSwarm(home: string, fleet: string, lines: LedgerLine[], swarm: string): string[] { + const out = new Set(membersOfSwarm(home, fleet, lines, swarm)); + for (const spawn of findEvents(lines, "swarm.spawn")) { + const id = String(spawn.swarm ?? ""); + if (id === "" || id === swarm) continue; + if (swarmAncestry(lines, id).some((line) => line.swarm === swarm)) for (const member of membersOfSwarm(home, fleet, lines, id)) out.add(member); + } + return [...out]; +} + +/** + * One `swarm.end` per swarm, written only once every nested swarm has its + * own and every started member has an end line that counts (rule 11). A + * member whose engine would not stop has none, so its swarm stays open and + * the row says why. The line goes through its once-marker. + */ +function endSwarmIfComplete(ctx: Ctx, fleet: string, swarm: string, by: string, rows: Row[]): void { + const lines = readLedger(ctx.home, fleet); + const existing = swarmEndOf(lines, swarm); + if (existing) { + rows.push({ kind: "swarm", swarm, state: String(existing.state), ended: false, note: "already ended" }); return; } + const nested = findEvents(lines, "swarm.spawn", { parent_swarm: swarm }).map((line) => String(line.swarm)); + const openNested = nested.filter((child) => !swarmEndOf(lines, child)); + if (openNested.length) { + rows.push({ kind: "swarm", swarm, state: "open", ended: false, note: `no swarm.end yet for nested ${openNested.join(", ")}` }); + return; + } + const members = membersOfSwarm(ctx.home, fleet, lines, swarm); const started = members.filter((member) => claimedBy(lines, member)); const ends = started.map((member) => endOf(lines, member)); const missing = started.filter((_, index) => ends[index] === null); @@ -336,10 +390,23 @@ async function stopSwarm(ctx: Ctx, fleet: string, swarm: string, by: string, row return; } const state: EndState = started.length ? (swarmEndState(ends) ?? "stopped") : "stopped"; - append(ctx.home, fleet, { event: "swarm.end", by, swarm, state }, { now: ctx.deps.now(), host: ctx.host }); + const line = appendOnce(ctx.home, fleet, { event: "swarm.end", by, swarm, state }, { now: ctx.deps.now(), host: ctx.host, once: markName("swarm.end", swarm) }); + if (!line) { + rows.push({ kind: "swarm", swarm, state, ended: false, note: "already ended" }); + return; + } rows.push({ kind: "swarm", swarm, state, ended: true }); } +/** Rule 11: nested swarms first, then the members through their engines, then one swarm.end. */ +async function stopSwarm(ctx: Ctx, fleet: string, swarm: string, by: string, rows: Row[]): Promise { + const lines = readLedger(ctx.home, fleet); + const nested = findEvents(lines, "swarm.spawn", { parent_swarm: swarm }).map((line) => String(line.swarm)); + for (const child of nested) await stopSwarm(ctx, fleet, child, by, rows); + for (const member of membersOfSwarm(ctx.home, fleet, lines, swarm)) await stopMember(ctx, fleet, member, by, rows); + endSwarmIfComplete(ctx, fleet, swarm, by, rows); +} + /** The swarms from `swarm` up to the top, by `parent_swarm`. */ function swarmAncestry(lines: LedgerLine[], swarm: string): LedgerLine[] { const out: LedgerLine[] = []; @@ -375,11 +442,97 @@ function findMember(home: string, fleets: string[], member: string): string | nu } function stopText(rows: Row[]): string[] { - return rows.map((row) => - row.kind === "member" - ? `${row.stopped ? "stopped" : "skipped"} ${row.member}${row.engine ? ` ${row.engine}` : ""}${row.state ? ` ${row.state}` : ""}${row.note ? ` (${row.note})` : ""}` - : `${row.ended ? "ended" : "left"} swarm ${row.swarm} ${row.state}${row.note ? ` (${row.note})` : ""}`, - ); + return rows.map((row) => { + if (row.kind === "member") { + const why = row.error ?? row.note; + return `${row.stopped ? "stopped" : "skipped"} ${row.member}${row.engine ? ` ${row.engine}` : ""}${row.state ? ` ${row.state}` : ""}${why ? ` (${why})` : ""}`; + } + return `${row.ended ? "ended" : "left"} swarm ${row.swarm} ${row.state}${row.note ? ` (${row.note})` : ""}`; + }); +} + +/** + * A swarm's effective ceiling now: the fleet's whole ceiling, the spawner's + * root approvals entering at the root in the implicit fleet, merged down the + * swarm path with the latest caps last. A swarm the sysop started by hand in + * the implicit fleet has no one approvals: each member supplies its own. + */ +function swarmCeiling(ctx: Ctx, lines: LedgerLine[], fleet: string, swarm: string): Ceiling { + const base = fleetCeiling(lines, fleet, ctx.implicit.ceiling); + if (base.approvals === undefined && isImplicitFleet(lines, fleet)) { + const spawn = spawnOf(lines, swarm); + const spawner = spawn && spawn.by !== "sysop" ? memberFacts(ctx.home, fleet, lines, spawn.by) : null; + if (spawner) base.approvals = rootApprovals(rootOf(ctx.home, lines, spawner)); + } + return effectiveCeiling(base, lines, swarmChain(lines, swarm)); +} + +function showValue(value: unknown): string { + return Array.isArray(value) ? value.join(",") : value === undefined || value === null ? "none" : String(value); +} + +/** Has spend in the budget's unit reached the budget? Other units are shown, not summed (open question, 0.1). */ +function overBudget(budget: unknown, spend: Record): boolean { + const cap = parseBudget(budget); + return cap !== null && (spend[cap.unit] ?? 0) >= cap.amount; +} + +/** + * Rule 6, run by the sysop's own `tree`: a working member whose effective + * ceiling `until` has passed is stopped through its engine and ends + * `timeout`; a fleet or swarm whose summed `member.spend` in the budget's + * unit has reached its budget has every working member under it stopped, + * each ending `budget`. Then each swarm touched gets its `swarm.end` when it + * is complete, nested first. A member the roster already says is gone is left + * to the lost pass; a member whose engine says no keeps its row and is tried + * again next time. The tree nodes are updated in place so the render shows + * what was done. + */ +async function enforce(ctx: Ctx, tree: Tree, by: string, rows: Row[]): Promise { + const now = ctx.deps.now(); + const working = flattenMembers(tree).filter(({ member }) => member.state === "working" && !member.roster && member.alive !== false); + for (const fleet of tree.fleets) { + const lines = readLedger(ctx.home, fleet.fleet); + const records = readRecords(ctx.home, fleet.fleet); + const mine = working.filter((entry) => entry.fleet === fleet.fleet).map((entry) => entry.member); + const swarms = new Map(flattenSwarms(tree).filter((entry) => entry.fleet === fleet.fleet).map((entry) => [entry.swarm.swarm, entry.swarm])); + const touched = new Set(); + + const stopAs = async (node: MemberNode, state: EndState): Promise => { + if (node.state !== "working") return; + await stopMember(ctx, fleet.fleet, node.member, by, rows, state); + const row = rows[rows.length - 1]; + if (row.kind === "member" && row.stopped) { + node.state = state; + node.ended = isoNow(now); + } + if (node.swarm) touched.add(node.swarm); + }; + + if (overBudget(fleet.ceiling.budget, fleet.spend)) for (const node of mine) await stopAs(node, "budget"); + for (const swarm of swarms.values()) { + if (!overBudget((swarm.effective ?? swarm.ceiling).budget, swarm.spend)) continue; + const under = new Set(membersUnderSwarm(ctx.home, fleet.fleet, lines, swarm.swarm)); + for (const node of mine) if (under.has(node.member)) await stopAs(node, "budget"); + touched.add(swarm.swarm); + } + for (const node of mine) { + if (node.state !== "working") continue; + const facts = memberFacts(ctx.home, fleet.fleet, lines, node.member, records); + if (!facts) continue; + const allowed = memberCeiling(ctx.home, lines, facts, ctx.implicit); + if (allowed.until !== undefined && !isNarrower("until", isoNow(now), allowed.until)) await stopAs(node, "timeout"); + } + + // Nested swarms end before the swarms that hold them. + const ordered = [...touched].sort((a, b) => swarmAncestry(lines, b).length - swarmAncestry(lines, a).length); + for (const swarm of ordered) { + endSwarmIfComplete(ctx, fleet.fleet, swarm, by, rows); + const row = rows[rows.length - 1]; + const node = swarms.get(swarm); + if (node && row.kind === "swarm" && row.ended) node.state = row.state as EndState; + } + } } // --------------------------------------------------------------------------- @@ -494,6 +647,14 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = fleet = found; kind = "swarm"; if (Object.keys(ceiling).length === 0) fail("cap on a swarm needs at least one key to narrow", EXIT.INVALID); + // Rule 3: a cap on a swarm only narrows. A key that would widen is refused before anything is written, + // so the two reference readers, one of which honours what the ledger says, never disagree on the swarm. + const current = swarmCeiling(ctx, readLedger(ctx.home, fleet), fleet, target); + for (const key of CEILING_KEYS) { + if (ceiling[key] === undefined || isNarrower(key, ceiling[key], current[key])) continue; + const allowed = current[key] ?? (key === "approvals" ? "native" : key === "depth" ? 1 : undefined); + fail(`cap on swarm ${target} never widens: ${key} ${showValue(ceiling[key])} is not within ${showValue(allowed)}`, EXIT.INVALID); + } } const line = append(ctx.home, fleet, { event: "fleet.cap", by: "sysop", target, ceiling }, { now, host: ctx.host }); @@ -501,14 +662,12 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = const rows: Row[] = []; const lines = readLedger(ctx.home, fleet); const records = readRecords(ctx.home, fleet); - const inScope = kind === "fleet" ? [...records.keys()] : membersOfSwarm(ctx.home, fleet, lines, target).concat( - findEvents(lines, "swarm.spawn").filter((spawn) => swarmAncestry(lines, String(spawn.swarm)).some((s) => s.swarm === target)).flatMap((spawn) => membersOfSwarm(ctx.home, fleet, lines, String(spawn.swarm))), - ); + const inScope = kind === "fleet" ? [...records.keys()] : membersUnderSwarm(ctx.home, fleet, lines, target); for (const member of new Set(inScope)) { - if (!claimedBy(lines, member) || endOf(lines, member)) continue; - const record = records.get(member); - const start = claimedBy(lines, member)!; - const facts: FleetRecord = record ?? { openfleet: "0.1", fleet, sysop: "", member, swarm: start.swarm, parent: start.parent, depth: start.depth, engine: start.engine, host: start.host, approvals: start.approvals }; + const start = claimedBy(lines, member); + if (!start || endOf(lines, member)) continue; + const facts = memberFacts(ctx.home, fleet, lines, member, records); + if (!facts) continue; const allowed = memberCeiling(ctx.home, lines, facts, ctx.implicit); const approvals: Approvals = (start.approvals ?? facts.approvals) === "bypass" ? "bypass" : "native"; const above = @@ -518,6 +677,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = !isNarrower("until", isoNow(now), allowed.until); if (above) await stopMember(ctx, fleet, member, "sysop", rows); } + exitOnEngineFailure(rows); emit(ctx, { target, kind, fleet, ceiling, at: line.at, stopped: report(rows).members }, () => [ `capped ${kind} ${target}: ${JSON.stringify(ceiling)}`, ...stopText(rows), @@ -530,15 +690,19 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = .argument("[fleet]", "one fleet; default every fleet under the home") .option("--no-roster", "do not read the engine rosters (claude agents, moshcode herd)") .option("--json", "print the tree as JSON") - .description("Render a fleet, or every fleet on this host, as a tree: swarms, members, state, engine, spend and a mark on every bypass member.") + .description("Render a fleet, or every fleet on this host, as a tree: swarms, members, state, engine, spend and a mark on every bypass member. Run by the sysop, it also stops what is past its deadline or over its budget.") .action(async (fleet: string | undefined, opts: { roster?: boolean; json?: boolean }, command: Command) => run(deps, async () => { const ctx = ctxOf(command, deps, opts); if (fleet && !listFleets(ctx.home).includes(fleet) && fleet !== ctx.implicit.id) fail(`no fleet named ${fleet} under ${ctx.home}`, EXIT.NOT_FOUND); const rosters = opts.roster === false ? {} : deps.rosters; const tree: Tree = await fold(ctx.home, rosters, { implicit: ctx.implicit, host: ctx.host, ...(fleet ? { fleet } : {}) }); - // A recorded member its engine no longer lists, with no end line, is lost (verb table, tree). - const by = deps.env.OPENFLEET_MEMBER ?? "sysop"; + const caller = deps.env.OPENFLEET_MEMBER; + const by = caller ?? "sysop"; + // Rule 6 is the sysop's to enforce: an agent's tree renders and marks, it stops nothing outside its subtree (rule 2). + const rows: Row[] = []; + if (!caller) await enforce(ctx, tree, by, rows); + // A recorded member its engine's roster can hold and no longer lists, with no end line, is lost (verb table, tree). for (const entry of flattenMembers(tree)) { const node = entry.member; if (node.roster || node.state !== "working" || node.alive !== false) continue; @@ -548,7 +712,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = node.ended = result.ended.at; } } - emit(ctx, tree, () => renderTree(tree, { host: ctx.host })); + emit(ctx, { ...tree, enforced: report(rows) }, () => [renderTree(tree, { host: ctx.host }), ...stopText(rows)]); }), ); @@ -575,6 +739,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = if (swarmAncestry(readLedger(ctx.home, target), entry.swarm.swarm).length === 1) await stopSwarm(ctx, target, entry.swarm.swarm, by, rows); } for (const entry of flattenMembers(tree)) if (!entry.swarm) await stopMember(ctx, target, entry.member.member, by, rows); + exitOnEngineFailure(rows); emit(ctx, { target, kind: "fleet", ...report(rows) }, () => stopText(rows)); return; } @@ -585,6 +750,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = fail(`stop refuses: swarm ${target} is outside the subtree ${caller} spawned`, EXIT.REFUSED); } await stopSwarm(ctx, swarmFleet, target, by, rows); + exitOnEngineFailure(rows); emit(ctx, { target, kind: "swarm", fleet: swarmFleet, ...report(rows) }, () => stopText(rows)); return; } @@ -598,6 +764,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = } await stopMember(ctx, memberFleet, target, by, rows); if (rows.some((row) => row.kind === "member" && !row.stopped && row.note && row.note !== "already ended")) process.exitCode = EXIT.NOT_FOUND; + exitOnEngineFailure(rows); emit(ctx, { target, kind: "member", fleet: memberFleet, ...report(rows) }, () => stopText(rows)); }), ); diff --git a/packages/openfleet/src/context.test.ts b/packages/openfleet/src/context.test.ts index 94ee71a..1bc2d5e 100644 --- a/packages/openfleet/src/context.test.ts +++ b/packages/openfleet/src/context.test.ts @@ -1,8 +1,9 @@ import { existsSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { claimOrDerive, context, endMember, memberCeiling, startMember } from "./context.js"; -import { append, findEvents, readLedger, readRecord, recordPath, writeCurrent, writeRecord } from "./store.js"; +import { append, claimMark, findEvents, hasMark, markName, readLedger, readRecord, recordPath, writeCurrent, writeRecord } from "./store.js"; import { DEV, FLEET, PIECE_1, ROOT, cleanup, envFor, seedWorkedExample, tempHome } from "./test-helpers.js"; +import type { FleetRecord } from "./types.js"; const NOW = new Date("2026-09-13T05:41:12Z"); @@ -182,6 +183,41 @@ describe("deriving under the root: the planner's swarm of one", () => { if (resolution.kind !== "refused") throw new Error("unreachable"); expect(resolution.refusal).toEqual({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] }); }); + + it("names a derived claude-code background job by its job id and gives it no pid: claude stop takes the job id", () => { + const resolution = claimOrDerive({ + home, + env: rootEnv(home), + now: NOW, + host: "dev", + implicit: DEV, + session: { id: "abcd1234-0000-4000-8000-000000000009", member: "abcd1234", engine: "claude-code", cwd: "/x", pid: 5150, command: "claude --bg", approvals: "bypass" }, + }); + expect(resolution.kind).toBe("derive"); + if (resolution.kind !== "derive") throw new Error("unreachable"); + expect(resolution.record.member).toBe("abcd1234"); + expect(resolution.record).not.toHaveProperty("session"); + expect(resolution.spawned?.pieces).toEqual([{ member: "abcd1234" }]); + }); + + it("corrects a derived record's approvals to the engine's word at start, so the record, the check and member.start agree", () => { + // SessionStart guessed native from the command line; the permission mode says bypass, which the bypass root allows. + const derived = claimOrDerive({ home, env: rootEnv(home), now: NOW, host: "dev", implicit: DEV, session: { id: "guessed", engine: "claude-p", cwd: "/x", pid: 1, approvals: "native" } }); + if (derived.kind !== "derive") throw new Error("unreachable"); + expect(derived.record.approvals).toBe("native"); + const result = startMember(home, derived.record, { sessionId: "guessed", approvals: "bypass", now: NOW, host: "dev", implicit: DEV, derived: true }); + expect(result.started?.approvals).toBe("bypass"); + expect(result.record.approvals).toBe("bypass"); + expect(readRecord(recordPath(home, FLEET, "guessed"))?.approvals).toBe("bypass"); + // The ceiling still rules: under a native root the same correction is refused, and the record stays unclaimed. + writeRecord(home, { ...ROOT, member: "bbbb2222", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } }); + append(home, FLEET, { event: "member.start", by: "bbbb2222", member: "bbbb2222", session: "bbbb2222", depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" }); + const under = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "bbbb2222") }), now: NOW, host: "dev", implicit: DEV, session: { id: "guessed-2", engine: "claude-p", cwd: "/x", approvals: "native" } }); + if (under.kind !== "derive") throw new Error("unreachable"); + const refused = startMember(home, under.record, { sessionId: "guessed-2", approvals: "bypass", now: NOW, host: "dev", implicit: DEV, derived: true }); + expect(refused.refused?.refusal.key).toBe("approvals"); + expect(refused.started).toBeNull(); + }); }); describe("root and orphan records", () => { @@ -229,6 +265,41 @@ describe("root and orphan records", () => { expect(result.started?.approvals).toBe("bypass"); }); + it("starts a parentless bypass member whose writer left approvals out of the ceiling: the record's own approvals rule, and the engine fills the key", () => { + // The record moshcode writes for a swarm the sysop runs by hand: no parent, depth 0, bypass, a ceiling with no approvals key. + append(home, FLEET, { at: "2026-09-13T05:41:00Z", event: "swarm.spawn", by: "sysop", swarm: "hello-0541", task: "say hello", ceiling: { fan_out: 2, until: "2026-09-13T06:11:00Z" }, pieces: [{ member: "hello-0541-1", title: "hello" }] }, { host: "dev" }); + const record: FleetRecord = { + openfleet: "0.1", + fleet: FLEET, + sysop: FLEET, + member: "hello-0541-1", + swarm: "hello-0541", + task: "say hello", + piece: { title: "hello" }, + depth: 0, + engine: "moshcode/claude", + session: "hello-0541-1", + host: "dev", + cwd: "/x", + started: "2026-09-13T05:41:00Z", + approvals: "bypass", + ceiling: { depth: 1, hosts: ["dev"], fan_out: 2, until: "2026-09-13T06:11:00Z" }, + }; + writeRecord(home, record); + // The reader never takes an absent key for native on a parentless record in the implicit fleet. + expect(memberCeiling(home, readLedger(home, FLEET), record, DEV).approvals).toBe("bypass"); + const result = startMember(home, record, { sessionId: "pane", approvals: "bypass", now: NOW, host: "dev", implicit: DEV }); + expect(result.refused).toBeNull(); + expect(result.started).toMatchObject({ member: "hello-0541-1", session: "hello-0541-1", approvals: "bypass", depth: 0, swarm: "hello-0541" }); + expect(result.record.ceiling).toEqual({ approvals: "bypass", depth: 1, hosts: ["dev"], fan_out: 2, until: "2026-09-13T06:11:00Z" }); + expect(readRecord(recordPath(home, FLEET, "hello-0541-1"))?.ceiling?.approvals).toBe("bypass"); + // A root with no ceiling at all gets the implicit root ceiling written whole. + writeRecord(home, { openfleet: "0.1", fleet: FLEET, sysop: FLEET, member: "thin-root", approvals: "bypass" }); + const thin = startMember(home, { openfleet: "0.1", fleet: FLEET, sysop: FLEET, member: "thin-root", approvals: "bypass" }, { sessionId: "thin-root", approvals: "bypass", now: NOW, host: "dev", implicit: DEV }); + expect(thin.started?.approvals).toBe("bypass"); + expect(readRecord(recordPath(home, FLEET, "thin-root"))?.ceiling).toEqual({ approvals: "bypass", depth: 1, hosts: ["dev"] }); + }); + it("an orphan root gets ceiling approvals native and is refused when it runs with bypass, by its own member", () => { const resolution = claimOrDerive({ home, env: envFor(home), now: NOW, host: "dev", implicit: DEV, session: { id: "orphan-1", engine: "claude-p", cwd: "/x", approvals: "bypass", orphan: true } }); if (resolution.kind !== "root") throw new Error("unreachable"); @@ -274,6 +345,50 @@ describe("ending", () => { expect(second.already?.state).toBe("done"); }); + it("leaves a spawner's one-piece swarm for the spawner to end", () => { + // A swarm moshcode wrote with a single piece is not a swarm of one the + // engine minted: its id is not -. The member ends itself only. + append(home, FLEET, { event: "swarm.spawn", by: "460a4502", swarm: "gate-two-1030", task: "gate", ceiling: {}, pieces: [{ member: "gate-two-1030-1", title: "one" }] }, { now: NOW, host: "dev" }); + writeRecord(home, { ...PIECE_1, member: "gate-two-1030-1", swarm: "gate-two-1030", piece: { title: "one" } }); + startMember(home, { ...PIECE_1, member: "gate-two-1030-1", swarm: "gate-two-1030", piece: { title: "one" } }, { sessionId: "s1", approvals: "bypass", now: NOW, host: "dev", implicit: DEV }); + const ended = endMember(home, FLEET, "gate-two-1030-1", { state: "done", by: "gate-two-1030-1", now: NOW, host: "dev" }, "gate-two-1030"); + expect(ended.ended?.state).toBe("done"); + expect(ended.swarmEnded).toBeNull(); + expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: "gate-two-1030" }).length).toBe(0); + }); + + it("writes nothing when another writer holds the once-marker: member.start, member.end, swarm.end", () => { + // The spawner took the member.start marker a moment ago; its line is not in the ledger yet. + claimMark(home, FLEET, markName("member.start", "create-two-0541-1")); + const before = readLedger(home, FLEET).length; + const start = startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV }); + expect(start).toMatchObject({ started: null, refused: null, already: null }); + expect(readLedger(home, FLEET).length).toBe(before); + // Once its line lands, the ledger check answers first. + append(home, FLEET, { event: "member.start", by: "460a4502", member: "create-two-0541-1", session: "172ffd83", depth: 1, engine: "claude-code", approvals: "bypass" }, { now: NOW, host: "dev" }); + expect(startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV }).already?.by).toBe("460a4502"); + // The same for the end: a held plain marker means a real end is in flight, so neither a second real end nor a lost one is written. + claimMark(home, FLEET, markName("member.end", "create-two-0541-1")); + expect(endMember(home, FLEET, "create-two-0541-1", { state: "done", by: "create-two-0541-1", now: NOW, host: "dev" }, "create-two-0541").ended).toBeNull(); + expect(endMember(home, FLEET, "create-two-0541-1", { state: "lost", by: "sysop", now: NOW, host: "dev" }, "create-two-0541").ended).toBeNull(); + expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-1" })).toEqual([]); + // A lost end takes its own marker, so a real end after it still lands and takes the plain one. + startMember(home, { ...PIECE_1, member: "create-two-0541-2", session: "create-two-0541-2" }, { sessionId: "s2", approvals: "bypass", now: NOW, host: "dev", implicit: DEV }); + expect(endMember(home, FLEET, "create-two-0541-2", { state: "lost", by: "sysop", now: NOW, host: "dev" }, "create-two-0541").ended?.state).toBe("lost"); + expect(hasMark(home, FLEET, markName("member.end", "create-two-0541-2", true))).toBe(true); + expect(hasMark(home, FLEET, markName("member.end", "create-two-0541-2"))).toBe(false); + expect(endMember(home, FLEET, "create-two-0541-2", { state: "done", by: "create-two-0541-2", now: NOW, host: "dev" }, "create-two-0541").ended?.state).toBe("done"); + expect(hasMark(home, FLEET, markName("member.end", "create-two-0541-2"))).toBe(true); + // A swarm.end marker held elsewhere keeps a derived swarm of one from ending twice. + const derived = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502") }), now: NOW, host: "dev", implicit: DEV, session: { id: "p9", engine: "claude-p", cwd: "/x", approvals: "native" } }); + if (derived.kind !== "derive") throw new Error("unreachable"); + startMember(home, derived.record, { sessionId: "p9", approvals: "native", now: NOW, host: "dev", implicit: DEV }); + claimMark(home, FLEET, markName("swarm.end", derived.record.swarm!)); + const ended = endMember(home, FLEET, "p9", { state: "done", by: "p9", now: NOW, host: "dev" }, derived.record.swarm); + expect(ended.ended?.state).toBe("done"); + expect(ended.swarmEnded).toBeNull(); + }); + it("lets a real end supersede lost, and ends a derived swarm of one with the member", () => { const derived = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502") }), now: NOW, host: "dev", implicit: DEV, session: { id: "p2", engine: "claude-p", cwd: "/x", approvals: "native" } }); if (derived.kind !== "derive") throw new Error("unreachable"); @@ -318,6 +433,28 @@ describe("context and the effective ceiling", () => { expect(memberCeiling(home, readLedger(home, FLEET), PIECE_1, DEV)).toEqual({ ...PIECE_1.ceiling, approvals: "native", until: "2026-09-13T06:00:00Z" }); }); + it("lets a fleet-target cap widen past the ceiling copied into a record: the copy is a snapshot, not an input", () => { + // The sysop raises the implicit fleet to depth 2 so its members may spawn. + append(home, FLEET, { event: "fleet.cap", by: "sysop", target: FLEET, ceiling: { depth: 2, hosts: ["dev", "netcup"] } }, { host: "dev" }); + const allowed = memberCeiling(home, readLedger(home, FLEET), PIECE_1, DEV); + // Root approvals still enter at the root when the cap names none (the implicit fleet has no fleet-level approvals). + expect(allowed).toEqual({ approvals: "bypass", depth: 2, hosts: ["dev", "netcup"], fan_out: 4, until: "2026-09-13T06:11:01Z" }); + // A claude -p under the claimed piece is now depth 2, within the raised ceiling: derived, not refused. + startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV }); + const child = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1") }), now: NOW, host: "netcup", implicit: DEV, session: { id: "grandchild", engine: "claude-p", cwd: "/x", pid: 7, approvals: "bypass" } }); + expect(child.kind).toBe("derive"); + if (child.kind !== "derive") throw new Error("unreachable"); + expect(child.record).toMatchObject({ depth: 2, host: "netcup", ceiling: { depth: 2, approvals: "bypass" } }); + // A cap on the implicit fleet that names approvals applies to every root's subtree. + append(home, FLEET, { event: "fleet.cap", by: "sysop", target: FLEET, ceiling: { approvals: "native", depth: 2, hosts: ["dev"] } }, { host: "dev" }); + expect(memberCeiling(home, readLedger(home, FLEET), PIECE_1, DEV).approvals).toBe("native"); + // And the ceiling of the swarm the derive joined includes that swarm's own cap, applied last. + append(home, FLEET, { event: "fleet.cap", by: "sysop", target: "create-two-0541", ceiling: { fan_out: 1 } }, { host: "dev" }); + const joined = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502"), OPENFLEET_SWARM: "create-two-0541" }), now: NOW, host: "dev", implicit: DEV, session: { id: "joiner-2", engine: "claude-p", cwd: "/x", pid: 8, approvals: "native" } }); + if (joined.kind !== "derive") throw new Error("unreachable"); + expect(joined.record.ceiling).toEqual({ approvals: "native", depth: 2, hosts: ["dev"], fan_out: 1, until: "2026-09-13T06:11:01Z" }); + }); + it("computes a ceiling for a record that carries none: the fleet's, the root's approvals, the swarm path", () => { const { ceiling: _dropped, ...thin } = PIECE_1; void _dropped; diff --git a/packages/openfleet/src/context.ts b/packages/openfleet/src/context.ts index 07820ef..4625989 100644 --- a/packages/openfleet/src/context.ts +++ b/packages/openfleet/src/context.ts @@ -9,16 +9,19 @@ * make the same decision from the same ledger. */ -import { checkCeiling, effectiveCeiling, fleetCeiling, isImplicitFleet, mergeCeiling, rootApprovals, swarmChain } from "./ceiling.js"; +import { checkCeiling, effectiveCeiling, fleetCeiling, isImplicitFleet, rootApprovals, swarmChain } from "./ceiling.js"; import { nextSwarmOfOne } from "./swarm.js"; import { append, + appendOnce, claimedBy, endOf, findEvents, + hasMark, home as homeOf, implicitFleet, isoNow, + markName, readCurrent, readLedger, readRecord, @@ -105,29 +108,21 @@ export function rootOf(homeDir: string, lines: LedgerLine[], record: FleetRecord } /** - * The effective ceiling a member is under now. The record's own `ceiling` is - * what it was started under; the latest `fleet.cap` for its fleet and for any - * swarm on its path wins over that copy (rule 7). A record with no ceiling - * gets the fleet's, with the root's approvals entering at the root in the - * implicit fleet, merged down its swarm path. + * The effective ceiling a member is under now, rebuilt from the ledger every + * time. The record's own `ceiling` is a snapshot of what it was started + * under and never an input here: the latest `fleet.cap` for its fleet wins + * over that copy (rule 7), widening included, so the sysop can raise a fleet + * and have its running members read the new ceiling. The order is the spec's: + * the fleet's whole ceiling (latest fleet-target cap, else `fleet.open`, else + * the implicit fleet's), with the root's own approvals entering at the root + * in the implicit fleet when no cap names one, then each `swarm.spawn` + * narrowing down the member's path, then the latest cap on any swarm on that + * path, applied last. A merge never widens. */ export function memberCeiling(homeDir: string, lines: LedgerLine[], record: FleetRecord, implicit: ImplicitFleet): Ceiling { - const chain = swarmChain(lines, record.swarm); - let base: Ceiling; - if (record.ceiling && typeof record.ceiling === "object") { - base = { ...record.ceiling }; - } else { - base = fleetCeiling(lines, record.fleet, implicit.ceiling); - if (isImplicitFleet(lines, record.fleet)) base.approvals = rootApprovals(rootOf(homeDir, lines, record)); - base = effectiveCeiling(base, lines, chain); - } - const fleetCaps = findEvents(lines, "fleet.cap", { target: record.fleet }); - if (fleetCaps.length) base = mergeCeiling(base, fleetCaps[fleetCaps.length - 1].ceiling); - for (const swarm of chain) { - const caps = findEvents(lines, "fleet.cap", { target: swarm }); - if (caps.length) base = mergeCeiling(base, caps[caps.length - 1].ceiling); - } - return base; + const base = fleetCeiling(lines, record.fleet, implicit.ceiling); + if (base.approvals === undefined && isImplicitFleet(lines, record.fleet)) base.approvals = rootApprovals(rootOf(homeDir, lines, record)); + return effectiveCeiling(base, lines, swarmChain(lines, record.swarm)); } // --------------------------------------------------------------------------- @@ -201,20 +196,19 @@ function derive( opts: { home: string; env: Env; now: Date; host: string; implicit: ImplicitFleet; session: SessionFacts }, ): Resolution { const { home, env, now, host, implicit, session } = opts; - const member = session.id; + // A background job's member is its job id, as for a root; otherwise the engine's session id. + const member = session.member ?? session.id; const depth = (parent.depth ?? 0) + 1; // The swarm the child joins, when the parent spawned the one the environment names. let swarm: string; let task: string | undefined; - let narrowing: Ceiling | undefined; let spawnToWrite: LedgerInput | null = null; const named = env.OPENFLEET_SWARM; const namedSpawn = named && named !== parent.swarm ? spawnOf(lines, named) : null; if (named && namedSpawn && namedSpawn.by === parent.member) { swarm = named; task = namedSpawn.task; - narrowing = namedSpawn.ceiling; } else { const existing = findEvents(lines, "swarm.spawn").map((line) => String(line.swarm ?? "")); swarm = nextSwarmOfOne(parent.member, existing); @@ -230,8 +224,9 @@ function derive( }; } - const parentCeiling = memberCeiling(home, lines, parent, implicit); - const ceiling = mergeCeiling(parentCeiling, narrowing); + // The parent's effective ceiling, then the joined swarm's own narrowing and + // any cap on it. A swarm of one has no `swarm.spawn` yet and narrows nothing. + const ceiling = effectiveCeiling(memberCeiling(home, lines, parent, implicit), lines, [swarm]); const refusal = checkCeiling({ approvals: session.approvals, depth, hosts: [host], until: isoNow(now) }, ceiling); if (refusal) { const line = append( @@ -256,7 +251,8 @@ function derive( ...(task !== undefined ? { task } : {}), depth, engine: session.engine, - ...(session.pid !== undefined ? { session: String(session.pid) } : {}), + // Only a `claude -p` is stopped by pid; a Claude Code job is stopped by its job id, which is its member. + ...(session.pid !== undefined && session.engine === "claude-p" ? { session: String(session.pid) } : {}), host, cwd: session.cwd, started: isoNow(now), @@ -309,23 +305,37 @@ function root(opts: { home: string; env: Env; now: Date; host: string; implicit: export interface StartResult { started: LedgerLine | null; refused: { refusal: Refusal; line: LedgerLine } | null; - /** The record was already claimed; nothing was written. */ + /** + * The record was already claimed, or another writer took the `member.start` + * marker first; nothing was written. Null beside a null `started` and + * `refused` means the marker was taken and the line is not visible yet. + */ already: LedgerLine | null; - /** The record as it stands: rewritten when a hand-started root's real approvals differed from the guess. */ + /** The record as it stands: rewritten when the engine's word on approvals replaced the starter's guess. */ record: FleetRecord; } /** * Claim a record: check the effective ceiling, then write `member.start` with - * `by` the member itself. A refusal writes `ceiling.refuse` instead, with `by` - * the record's parent, else the caller's `OPENFLEET_MEMBER`, else the record's - * own member when it carries `orphan`, else `sysop` for a root the human - * started by hand. + * `by` the member itself, under the once-marker so a spawner writing the same + * line on the member's behalf cannot double it. A refusal writes + * `ceiling.refuse` instead, with `by` the record's parent, else the caller's + * `OPENFLEET_MEMBER`, else the record's own member when it carries `orphan`, + * else `sysop` for a root the human started by hand. */ export function startMember( homeDir: string, given: FleetRecord, - facts: { sessionId: string; approvals: Approvals; env?: Env; now?: Date; host?: string; implicit?: ImplicitFleet }, + facts: { + sessionId: string; + approvals: Approvals; + env?: Env; + now?: Date; + host?: string; + implicit?: ImplicitFleet; + /** The record was derived by this engine at SessionStart from a guess at approvals. */ + derived?: boolean; + }, ): StartResult { const now = facts.now ?? new Date(); const implicit = facts.implicit ?? implicitFleet(); @@ -336,11 +346,19 @@ export function startMember( if (already) return { started: null, refused: null, already, record }; // A root the sysop started by hand runs under the approvals it was started - // with (rule 12). The starter may have guessed those before the engine said; - // the engine's word replaces the guess while the record is still unclaimed. + // with (rule 12), and its record's ceiling must carry them: in the implicit + // fleet there is no fleet-level approvals, each root supplies its own. The + // starter may have guessed, or left the key out; the engine's word fills + // the record while it is still unclaimed. A record this engine derived at + // SessionStart from a guess is corrected the same way, so the record, the + // check and the `member.start` agree. const handStartedRoot = !record.parent && !record.orphan && isImplicitFleet(lines, record.fleet); - if (handStartedRoot && record.approvals !== facts.approvals) { - record = { ...record, approvals: facts.approvals, ceiling: { ...(record.ceiling ?? {}), approvals: facts.approvals } }; + if (handStartedRoot && (record.approvals !== facts.approvals || record.ceiling?.approvals === undefined)) { + const ceiling: Ceiling = record.ceiling ? { ...record.ceiling, approvals: facts.approvals } : { approvals: facts.approvals, depth: 1, hosts: [host] }; + record = { ...record, approvals: facts.approvals, ceiling }; + replaceUnclaimedRecord(homeDir, record); + } else if (facts.derived && record.approvals !== facts.approvals) { + record = { ...record, approvals: facts.approvals }; replaceUnclaimedRecord(homeDir, record); } @@ -357,7 +375,7 @@ export function startMember( return { started: null, refused: { refusal, line }, already: null, record }; } - const started = append( + const started = appendOnce( homeDir, record.fleet, { @@ -373,8 +391,9 @@ export function startMember( approvals: facts.approvals, ...(record.piece ? { piece: record.piece } : {}), }, - { now, host }, + { now, host, once: markName("member.start", record.member) }, ); + if (!started) return { started: null, refused: null, already: claimedBy(readLedger(homeDir, record.fleet), record.member), record }; return { started, refused: null, already: null, record }; } @@ -391,22 +410,27 @@ export interface EndFacts { export interface EndResult { ended: LedgerLine | null; swarmEnded: LedgerLine | null; - /** An end line already counted; nothing was written. */ + /** An end line already counted, or another writer holds the marker; nothing was written. */ already: LedgerLine | null; } /** * End a member: one `member.end` that counts (a `lost` line may be superseded * by a real one, anything else stands), then, for a swarm of one the engine - * derived, that swarm's `swarm.end` with the same state. + * derived, that swarm's `swarm.end` with the same state. Every line goes + * through its once-marker; a `lost` end takes `member.end..lost` so the + * real end can still follow it and take the plain marker. */ export function endMember(homeDir: string, fleet: string, member: string, facts: EndFacts, swarm?: string): EndResult { const now = facts.now ?? new Date(); const lines = readLedger(homeDir, fleet); const existing = endOf(lines, member); if (existing && !(existing.state === "lost" && facts.state !== "lost")) return { ended: null, swarmEnded: null, already: existing }; + const lost = facts.state === "lost"; + // A real end in flight (marker taken, line not yet visible) beats a lost one. + if (lost && hasMark(homeDir, fleet, markName("member.end", member))) return { ended: null, swarmEnded: null, already: existing }; - const ended = append( + const ended = appendOnce( homeDir, fleet, { @@ -418,19 +442,27 @@ export function endMember(homeDir: string, fleet: string, member: string, facts: ...(facts.total !== undefined ? { total: facts.total } : {}), ...(facts.links !== undefined ? { links: facts.links } : {}), }, - { now, host: facts.host }, + { now, host: facts.host, once: markName("member.end", member, lost) }, ); + if (!ended) return { ended: null, swarmEnded: null, already: endOf(readLedger(homeDir, fleet), member) }; let swarmEnded: LedgerLine | null = null; if (swarm) { const spawn = spawnOf(lines, swarm); - const ofOne = spawn?.pieces?.length === 1 && spawn.pieces[0]?.member === member; + // Only a swarm of one the engine itself minted ends with its member: the + // spawner is a member (never the sysop) and the id is -. A + // one-piece swarm a spawner such as moshcode wrote is that spawner's to end. + const minted = + typeof spawn?.by === "string" && + spawn.by !== "sysop" && + new RegExp(`^${spawn.by.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d+$`).test(swarm); + const ofOne = minted && spawn?.pieces?.length === 1 && spawn.pieces[0]?.member === member; if (ofOne && !swarmEndOf(lines, swarm)) { - swarmEnded = append( + swarmEnded = appendOnce( homeDir, fleet, { event: "swarm.end", by: facts.by, swarm, state: facts.state, ...(facts.summary !== undefined ? { summary: facts.summary } : {}) }, - { now, host: facts.host }, + { now, host: facts.host, once: markName("swarm.end", swarm) }, ); } } diff --git a/packages/openfleet/src/fold.test.ts b/packages/openfleet/src/fold.test.ts index 4022524..6833d06 100644 --- a/packages/openfleet/src/fold.test.ts +++ b/packages/openfleet/src/fold.test.ts @@ -113,14 +113,55 @@ describe("fold: opened fleets, nesting, spend and orphans", () => { const hand = node.swarms[0]; expect(hand).toMatchObject({ swarm: "hand-0501", by: "sysop", spend: { USD: 5, tokens: 1000 } }); expect(hand.members.length).toBe(1); - expect(hand.swarms.map((swarm) => swarm.swarm)).toEqual(["inner-0503"]); - expect(hand.swarms[0].members[0]).toMatchObject({ member: "inner-0503-1", spend: "5 USD", depth: 1, parent: "hand-0501-1" }); + // The nested swarm sits under the member that spawned it, the row that says who, not under the parent swarm. + expect(hand.swarms).toEqual([]); + const spawner = hand.members[0]; + expect(spawner.swarms.map((swarm) => swarm.swarm)).toEqual(["inner-0503"]); + expect(spawner.swarms[0].members[0]).toMatchObject({ member: "inner-0503-1", spend: "5 USD", depth: 1, parent: "hand-0501-1" }); + // A swarm carries what it narrowed and what it runs under: the fleet's budget reaches the row. + expect(hand.ceiling).toEqual({ fan_out: 2 }); + expect(hand.effective).toEqual({ approvals: "bypass", depth: 3, hosts: ["dev"], budget: "20 USD", fan_out: 2 }); + expect(spawner.swarms[0].effective).toEqual({ approvals: "bypass", depth: 3, hosts: ["dev"], budget: "20 USD", fan_out: 2 }); expect(node.spend).toEqual({ USD: 5, tokens: 1000 }); + expect(flattenSwarms(tree).map((entry) => [entry.swarm.swarm, entry.parentMember])).toEqual([["hand-0501", null], ["inner-0503", "hand-0501-1"]]); const text = renderTree(tree, { host: "dev" }); expect(text.split("\n")[0]).toBe("team-20260913 (fleet, sysop https://anthony.example/profile.md, approvals bypass, depth 3, hosts dev, budget 20 USD, spent 5/20 USD)"); - expect(text).toMatch(/└─ swarm hand-0501 +"by hand" +1\/2 members +1000 tokens, 5 USD/); + expect(text).toMatch(/└─ swarm hand-0501 +"by hand" +1\/2 members +5\/20 USD/); expect(text).toMatch(/├─ stray +claude-code +unclaimed {2}\[orphan\]/); + // The nested swarm inherits fan_out 2 and the fleet's budget. + expect(text).toMatch(/└─ swarm inner-0503 +"nested" +1\/2 members +5\/20 USD/); expect(text).toMatch(/└─ inner-0503-1 \(i1\) +moshcode\/kimi +working {2}5 USD/); }); }); + +describe("fold: which members a roster can hold", () => { + let home: string; + beforeEach(() => { + home = tempHome(); + }); + afterEach(() => cleanup(home)); + + it("never reads an interactive claude session as gone, and reads a background job or a pane as gone when its roster is readable and silent", async () => { + const interactive = "68aca9c1-1111-4222-8333-444455556666"; + // An interactive root: the member is the session UUID, there is no job id, and `claude agents` never lists it. + append(home, FLEET, { at: "2026-09-13T05:00:00Z", event: "member.start", by: interactive, member: interactive, session: interactive, depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" }); + // A background job: an 8-hex member the roster can hold. + append(home, FLEET, { at: "2026-09-13T05:01:00Z", event: "member.start", by: "b9fc0f52", member: "b9fc0f52", session: "b9fc0f52", depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" }); + // A claimed piece whose session is a job id. + append(home, FLEET, { at: "2026-09-13T05:02:00Z", event: "member.start", by: "piece-0502-1", member: "piece-0502-1", session: "172ffd83", depth: 1, engine: "claude-code", approvals: "native" }, { host: "dev" }); + // A tmux pane moshcode started: in the herd manifest under its member id. + append(home, FLEET, { at: "2026-09-13T05:03:00Z", event: "member.start", by: "piece-0502-2", member: "piece-0502-2", session: "%7", depth: 1, engine: "tmux", approvals: "native" }, { host: "dev" }); + // A claude -p: no roster holds it. + append(home, FLEET, { at: "2026-09-13T05:04:00Z", event: "member.start", by: "p1", member: "p1", session: "31337", depth: 1, engine: "claude-p", approvals: "native" }, { host: "dev" }); + const tree = await fold(home, { claude: async () => [], moshcode: async () => [] }, { implicit: DEV, host: "dev" }); + const alive = Object.fromEntries(tree.fleets[0].roots.map((node) => [node.member, node.alive])); + expect(alive).toEqual({ [interactive]: undefined, b9fc0f52: false, "piece-0502-1": false, "piece-0502-2": false, p1: undefined }); + const text = renderTree(tree, { host: "dev" }); + expect(text).not.toMatch(new RegExp(`${interactive}.*\\[gone\\]`)); + expect(text).toMatch(/b9fc0f52 +claude-code +working {2}\[gone\]/); + // With the rosters unreadable nothing is gone. + const blind = await fold(home, { claude: async () => null, moshcode: async () => null }, { implicit: DEV, host: "dev" }); + expect(blind.fleets[0].roots.every((node) => node.alive === undefined)).toBe(true); + }); +}); diff --git a/packages/openfleet/src/fold.ts b/packages/openfleet/src/fold.ts index f7a0390..9a62a30 100644 --- a/packages/openfleet/src/fold.ts +++ b/packages/openfleet/src/fold.ts @@ -6,9 +6,9 @@ * place a member with no record exists. */ -import { fleetCeiling, formatSpend, isImplicitFleet, sumSpend } from "./ceiling.js"; +import { effectiveCeiling, fleetCeiling, formatSpend, isImplicitFleet, sumSpend, swarmChain } from "./ceiling.js"; import { fleetSysop } from "./context.js"; -import { rosterFor } from "./rosters.js"; +import { rosterFor, rosterHolds } from "./rosters.js"; import { claimedBy, endOf, @@ -89,6 +89,7 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost } } + const ceiling = fleetCeiling(lines, fleet, implicit.ceiling); const pieceOf = new Map(); const swarms = new Map(); for (const spawn of findEvents(lines, "swarm.spawn")) { @@ -100,6 +101,7 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost by: spawn.by, ...(typeof spawn.parent_swarm === "string" ? { parent_swarm: spawn.parent_swarm } : {}), ceiling: spawn.ceiling && typeof spawn.ceiling === "object" ? spawn.ceiling : {}, + effective: effectiveCeiling(ceiling, lines, swarmChain(lines, spawn.swarm)), members: [], swarms: [], ...(end ? { state: end.state as EndState, ...(typeof end.summary === "string" ? { summary: end.summary } : {}) } : {}), @@ -119,8 +121,11 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost const end = endOf(lines, id); const piece = pieceOf.get(id); const engine = record?.engine ?? start?.engine; + const session = record?.session ?? start?.session; const row = matchRow(roster, [id, record?.session, start?.session]); const covering = rosterFor(engine); + // Not listed means gone only for a member the roster can hold: a claude-code background job, a moshcode pane. + const gone = covering !== null && roster.readable.has(covering) && rosterHolds(engine, id, session); const approvals: Approvals = (start?.approvals ?? record?.approvals) === "bypass" ? "bypass" : "native"; const owns = record?.piece?.owns ?? piece?.owns; const title = record?.piece?.title ?? piece?.title ?? row?.name; @@ -136,7 +141,7 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost approvals, ...(owns ? { owns } : {}), ...(record?.orphan ? { orphan: true } : {}), - ...(row ? { alive: true } : covering && roster.readable.has(covering) ? { alive: false } : {}), + ...(row ? { alive: true } : gone ? { alive: false } : {}), ...(latestSpend(lines, id) ? { spend: latestSpend(lines, id) } : {}), ...(start ? { started: start.at } : record?.started ? { started: record.started } : {}), ...(end ? { ended: end.at } : {}), @@ -151,7 +156,9 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost const byStart = (a: { started?: string; member?: string; swarm?: string }, b: typeof a) => String(a.started ?? "").localeCompare(String(b.started ?? "")) || String(a.member ?? a.swarm).localeCompare(String(b.member ?? b.swarm)); - // Members into swarms, swarms under their parent swarm or spawner, the rest at the top. + // Members into swarms. A swarm goes under the member that spawned it when + // that member is in this tree (the row that says who), else under its + // parent swarm, else at the fleet level: the sysop's, started by hand. const roots: MemberNode[] = []; for (const node of [...members.values()].sort(byStart)) { const swarm = node.swarm ? swarms.get(node.swarm) : undefined; @@ -160,13 +167,13 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost } const fleetSwarms: SwarmNode[] = []; for (const swarm of swarms.values()) { - const parentSwarm = swarm.parent_swarm ? swarms.get(swarm.parent_swarm) : undefined; - if (parentSwarm && parentSwarm !== swarm) { - parentSwarm.swarms.push(swarm); + const spawner = members.get(swarm.by); + if (spawner) { + spawner.swarms.push(swarm); continue; } - const spawner = members.get(swarm.by); - if (spawner) spawner.swarms.push(swarm); + const parentSwarm = swarm.parent_swarm ? swarms.get(swarm.parent_swarm) : undefined; + if (parentSwarm && parentSwarm !== swarm) parentSwarm.swarms.push(swarm); else fleetSwarms.push(swarm); } // Spend rolls up from the leaves: a swarm's total is its members' plus every swarm under them. @@ -178,7 +185,6 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost for (const swarm of fleetSwarms) sumSwarm(swarm); for (const root of roots) for (const swarm of root.swarms) sumSwarm(swarm); - const ceiling = fleetCeiling(lines, fleet, implicit.ceiling); const implicitHere = isImplicitFleet(lines, fleet); return { fleet, @@ -292,9 +298,11 @@ function memberRow(node: MemberNode, prefix: string, host: string): Row { function swarmRow(node: SwarmNode, prefix: string): Row { const count = node.members.length; - const size = node.ceiling.fan_out !== undefined ? `${count}/${String(node.ceiling.fan_out)} members` : `${count} member${count === 1 ? "" : "s"}`; - const rest = [...(node.state ? [node.state] : node.ceiling.until ? [`until ${hhmm(String(node.ceiling.until))}`] : [])]; - const spend = formatSpend(node.spend, typeof node.ceiling.budget === "string" ? node.ceiling.budget : undefined); + // The row shows what the swarm runs under, inherited keys included, not only what its spawner narrowed. + const ceiling = node.effective ?? node.ceiling; + const size = ceiling.fan_out !== undefined ? `${count}/${String(ceiling.fan_out)} members` : `${count} member${count === 1 ? "" : "s"}`; + const rest = [...(node.state ? [node.state] : ceiling.until ? [`until ${hhmm(String(ceiling.until))}`] : [])]; + const spend = formatSpend(node.spend, typeof ceiling.budget === "string" ? ceiling.budget : undefined); if (spend) rest.push(spend); return { prefix, label: `swarm ${node.swarm}`, title: quote(node.task), engine: size, rest: rest.join(" ") }; } diff --git a/packages/openfleet/src/hooks-install.test.ts b/packages/openfleet/src/hooks-install.test.ts index dba030f..5807313 100644 --- a/packages/openfleet/src/hooks-install.test.ts +++ b/packages/openfleet/src/hooks-install.test.ts @@ -8,7 +8,8 @@ describe("hook commands", () => { it("guard every event so a box without logicsrc stays silent, and let only UserPromptSubmit be heard", () => { expect(hookCommand("SessionStart")).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook SessionStart; exit 0"); expect(hookCommand("Stop")).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook Stop; exit 0"); - expect(hookCommand("UserPromptSubmit")).toBe("command -v logicsrc >/dev/null 2>&1 || exit 0; logicsrc fleet hook UserPromptSubmit"); + // Only the deliberate 2 (a refused start) reaches the engine; a crash or an older logicsrc on PATH is swallowed. + expect(hookCommand("UserPromptSubmit")).toBe('command -v logicsrc >/dev/null 2>&1 || exit 0; logicsrc fleet hook UserPromptSubmit; rc=$?; [ "$rc" -eq 2 ] && exit 2; exit 0'); expect(hookCommand("SessionStart")).not.toContain(">/dev/null 2>&1 && logicsrc fleet hook SessionStart >/dev/null"); const specs = hookSpecs(); expect(specs.map((spec) => spec.event)).toEqual([...HOOK_EVENTS]); diff --git a/packages/openfleet/src/hooks-install.ts b/packages/openfleet/src/hooks-install.ts index 04fcff2..d29fcc8 100644 --- a/packages/openfleet/src/hooks-install.ts +++ b/packages/openfleet/src/hooks-install.ts @@ -37,12 +37,14 @@ const GUARD = "command -v logicsrc >/dev/null 2>&1"; * * Every event but one ends in `; exit 0`: whatever happened, the engine * carries on. UserPromptSubmit is the one hook that must be heard: a start the - * ceiling refuses exits 2 (rule 5), so its guard is `|| exit 0` and the exit - * code is the handler's own. SessionStart's stdout is the member's context - * line, so nothing there is redirected. + * ceiling refuses exits 2 (rule 5), and only that code is passed on. Any other + * failure (a crash, a missing build, an older `logicsrc` on PATH with no + * `fleet`) would otherwise show as an error on every prompt, so it becomes 0. + * SessionStart's stdout is the member's context line, so nothing there is + * redirected. */ export function hookCommand(event: HookEvent): string { - if (event === "UserPromptSubmit") return `${GUARD} || exit 0; logicsrc fleet hook ${event}`; + if (event === "UserPromptSubmit") return `${GUARD} || exit 0; logicsrc fleet hook ${event}; rc=$?; [ "$rc" -eq 2 ] && exit 2; exit 0`; return `${GUARD} && logicsrc fleet hook ${event}; exit 0`; } diff --git a/packages/openfleet/src/hooks.test.ts b/packages/openfleet/src/hooks.test.ts index 99c8f4b..9d0bf5c 100644 --- a/packages/openfleet/src/hooks.test.ts +++ b/packages/openfleet/src/hooks.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { startMember } from "./context.js"; import { contextLine, exportLines, ownJobDir, ownsPath, runHook, summaryOf, type HookIo } from "./hooks.js"; -import { findEvents, readLedger, readRecord, readSession, recordPath, writeCurrent, append } from "./store.js"; +import { append, endOf, findEvents, readLedger, readRecord, readSession, recordPath, writeCurrent } from "./store.js"; import { DEV, FLEET, PIECE_1, cleanup, envFor, seedWorkedExample, tempHome } from "./test-helpers.js"; import type { Env } from "./store.js"; @@ -284,6 +284,42 @@ describe("Stop and SessionEnd", () => { expect(findEvents(lines, "member.end", { member: SESSION })[0]).toMatchObject({ state: "done", summary: "the plan" }); expect(findEvents(lines, "swarm.end", { swarm: "460a4502-2" })[0]).toMatchObject({ by: SESSION, state: "done", summary: "the plan" }); }); + + it("supersedes a lost line a sysop tool wrote with the engine's own end, at SessionEnd and at a job's idle Stop", () => { + runHook("SessionStart", payload({ source: "startup" }), fake(home).io); + runHook("UserPromptSubmit", payload({ permission_mode: "auto" }), fake(home).io); + runHook("Stop", payload({ last_assistant_message: "finished", background_tasks: [] }), fake(home).io); + append(home, FLEET, { event: "member.end", by: "sysop", member: SESSION, state: "lost" }, { now: NOW, host: "dev" }); + runHook("SessionEnd", payload({ reason: "other" }), fake(home).io); + const ends = findEvents(readLedger(home, FLEET), "member.end", { member: SESSION }); + expect(ends.map((line) => line.state)).toEqual(["lost", "done"]); + expect(ends[1]).toMatchObject({ by: SESSION, summary: "finished" }); + expect(endOf(readLedger(home, FLEET), SESSION)?.state).toBe("done"); + + const { jobDir, jobId } = bgJob(); + const env = { CLAUDE_JOB_DIR: jobDir }; + const job = "68aca9c1-2222-4222-8333-444455556666"; + runHook("SessionStart", payload({ source: "startup", session_id: job }), fake(home, env).io); + runHook("UserPromptSubmit", payload({ session_id: job, permission_mode: "auto" }), fake(home, env).io); + append(home, FLEET, { event: "member.end", by: "sysop", member: jobId, state: "lost" }, { now: NOW, host: "dev" }); + runHook("Stop", payload({ session_id: job, last_assistant_message: "SUMMARY: shipped.", background_tasks: [] }), fake(home, env).io); + expect(endOf(readLedger(home, FLEET), jobId)).toMatchObject({ state: "done", summary: "SUMMARY: shipped.", total: "801101 tokens" }); + // A real end that already stands is never followed by another. + runHook("Stop", payload({ session_id: job, last_assistant_message: "again", background_tasks: [] }), fake(home, env).io); + expect(findEvents(readLedger(home, FLEET), "member.end", { member: jobId }).length).toBe(2); + }); + + it("corrects a derived record's guessed approvals with the permission mode the engine reports", () => { + seedWorkedExample(home); + // The command line said nothing about permissions, so SessionStart guessed native; the engine then says bypass. + const env = { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502"), CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }; + runHook("SessionStart", payload({ source: "startup" }), fake(home, env, { cmdline: ["claude", "-p", "plan it"] }).io); + expect(readRecord(recordPath(home, FLEET, SESSION))?.approvals).toBe("native"); + expect(runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions" }), fake(home, env).io).exit).toBe(0); + expect(readRecord(recordPath(home, FLEET, SESSION))?.approvals).toBe("bypass"); + expect(findEvents(readLedger(home, FLEET), "member.start", { member: SESSION })[0]?.approvals).toBe("bypass"); + expect(readSession(home, SESSION)?.approvals).toBe("bypass"); + }); }); describe("never failing the engine", () => { diff --git a/packages/openfleet/src/hooks.ts b/packages/openfleet/src/hooks.ts index fede990..5841d0d 100644 --- a/packages/openfleet/src/hooks.ts +++ b/packages/openfleet/src/hooks.ts @@ -279,7 +279,9 @@ export function handleUserPromptSubmit(payload: Payload, io: HookIo): HookResult const approvals: Approvals = payload.permission_mode === "bypassPermissions" ? "bypass" : "native"; const implicit = io.implicit ?? implicitFleet(); - const result = startMember(homeDir, record, { sessionId, approvals, env, now: io.now(), host: io.host ?? implicit.host, implicit }); + // A record this engine derived at SessionStart guessed approvals from the command line; the permission mode is the engine's word. + const derived = session.kind === "derive"; + const result = startMember(homeDir, record, { sessionId, approvals, env, now: io.now(), host: io.host ?? implicit.host, implicit, derived }); if (result.refused) { const reason = describeRefusal(result.refused.refusal); writeSession(homeDir, sessionId, { ...session, refused: { key: result.refused.refusal.key, reason } }); @@ -368,7 +370,9 @@ export function handleStop(payload: Payload, io: HookIo): HookResult { if (!jobDir || !session.record) return OK; if (!Array.isArray(payload.background_tasks) || payload.background_tasks.length > 0) return OK; const record = session.record; - if (endOf(readLedger(homeDir, record.fleet), record.member)) return OK; + // A `lost` line a sysop tool wrote is superseded by the engine's own end; anything else stands. + const existing = endOf(readLedger(homeDir, record.fleet), record.member); + if (existing && existing.state !== "lost") return OK; const state = stateJson(jobDir); endMember( homeDir, @@ -398,8 +402,8 @@ export function handleSessionEnd(payload: Payload, io: HookIo): HookResult { // clear, resume and logout hand the same work to another session; only a real exit ends the member. if (payload.reason !== undefined && payload.reason !== "other" && payload.reason !== "prompt_input_exit") return OK; const record = session.record; - const lines = readLedger(homeDir, record.fleet); - if (endOf(lines, record.member)) return OK; + const existing = endOf(readLedger(homeDir, record.fleet), record.member); + if (existing && existing.state !== "lost") return OK; const jobDir = ownJobDir(env, sessionId); const state = jobDir ? stateJson(jobDir) : null; endMember( diff --git a/packages/openfleet/src/rosters.ts b/packages/openfleet/src/rosters.ts index 6cafff6..12f8d17 100644 --- a/packages/openfleet/src/rosters.ts +++ b/packages/openfleet/src/rosters.ts @@ -184,6 +184,41 @@ export function defaultRosters(exec: Exec = realExec, env: Env = process.env): R /** Which roster answers for an engine string, so "not listed" can mean "gone" rather than "unknown". */ export function rosterFor(engine: string | undefined): keyof Rosters | null { if (engine === "claude-code") return "claude"; - if (engine?.startsWith("moshcode/")) return "moshcode"; + // moshcode names every pane it starts, tmux ones included, in its herd manifest. + if (engine?.startsWith("moshcode/") || engine === "tmux") return "moshcode"; + return null; +} + +/** A Claude Code job id: the first eight hex characters of its session id. */ +export const JOB_ID_RE = /^[0-9a-f]{8}$/i; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Can this engine's roster hold the member at all? `claude agents` lists + * background jobs only, so an interactive or `-p` session (a UUID member with + * no job id) is never in it and its absence says nothing. moshcode's manifest + * holds every pane it started. Only a member the roster can hold is "gone" + * when the roster no longer lists it. + */ +export function rosterHolds(engine: string | undefined, member: string, session: string | undefined): boolean { + const roster = rosterFor(engine); + if (roster === null) return false; + if (roster === "moshcode") return true; + return JOB_ID_RE.test(member) || (typeof session === "string" && JOB_ID_RE.test(session)); +} + +/** + * The job id `claude stop` takes for a claude-code member: the member id of a + * background job, else the first eight characters of the record's session + * when that is a session UUID. Null for an interactive session with no job + * id, which the tool cannot stop. A session equal to the member is the + * engine's own id echoed back in `member.start`, not a job handle. + */ +export function claudeJobId(member: string, session: string | undefined): string | null { + if (JOB_ID_RE.test(member)) return member; + if (typeof session !== "string" || session === member) return null; + if (JOB_ID_RE.test(session)) return session; + if (UUID_RE.test(session)) return session.slice(0, 8); return null; } diff --git a/packages/openfleet/src/store.test.ts b/packages/openfleet/src/store.test.ts index 43c29ee..c3b97ef 100644 --- a/packages/openfleet/src/store.test.ts +++ b/packages/openfleet/src/store.test.ts @@ -4,13 +4,17 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { RecordExistsError, append, + appendOnce, + claimMark, claimedBy, endOf, hasEvent, + hasMark, home, implicitFleet, ledgerPaths, listFleets, + markName, readCurrent, readLedger, readRecord, @@ -160,6 +164,24 @@ describe("the ledger", () => { expect(swarmEndState([end("lost")])).toBe("failed"); }); + it("takes a once-marker with an exclusive create, so a racing second writer writes nothing", () => { + expect(markName("member.start", "a")).toBe("member.start.a"); + expect(markName("member.end", "a", true)).toBe("member.end.a.lost"); + expect(markName("swarm.end", "s-1")).toBe("swarm.end.s-1"); + expect(hasMark(dir, FLEET, "member.start.a")).toBe(false); + expect(claimMark(dir, FLEET, "member.start.a")).toBe(true); + expect(claimMark(dir, FLEET, "member.start.a")).toBe(false); + expect(hasMark(dir, FLEET, "member.start.a")).toBe(true); + const path = join(dir, "fleets", FLEET, "marks", "member.start.a"); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(statSync(join(dir, "fleets", FLEET, "marks")).mode & 0o777).toBe(0o700); + const first = appendOnce(dir, FLEET, { at: "2026-09-13T05:41:36Z", event: "member.end", by: "a", member: "a", state: "done" }, { host: "dev", once: markName("member.end", "a") }); + expect(first?.state).toBe("done"); + const second = appendOnce(dir, FLEET, { at: "2026-09-13T05:41:37Z", event: "member.end", by: "sysop", member: "a", state: "stopped" }, { host: "dev", once: markName("member.end", "a") }); + expect(second).toBeNull(); + expect(readLedger(dir, FLEET).filter((line) => line.event === "member.end").length).toBe(1); + }); + it("keeps current and the per-session file under the home", () => { expect(readCurrent(dir)).toBeNull(); writeCurrent(dir, "fleet-20260913"); diff --git a/packages/openfleet/src/store.ts b/packages/openfleet/src/store.ts index 52e8801..bd6cea9 100644 --- a/packages/openfleet/src/store.ts +++ b/packages/openfleet/src/store.ts @@ -236,6 +236,63 @@ export function append( return line; } +// --------------------------------------------------------------------------- +// Once-markers: the lines that must never be written twice +// --------------------------------------------------------------------------- +// +// `member.start`, `member.end` and `swarm.end` are checked in the ledger before +// they are written, but a check followed by an append is not exclusion: the +// engine's hook and the spawner fire at the same moment. So each such line +// takes a marker first, an exclusive create under `fleets//marks/`, and +// the writer that loses the race writes nothing. Both reference writers use +// the same paths, so the rule holds across moshcode and these hooks. + +export type OnceEvent = "member.start" | "member.end" | "swarm.end"; + +export function marksDir(homeDir: string, fleet: string): string { + return join(fleetDir(homeDir, fleet), "marks"); +} + +/** + * The marker a line takes: `.`, where id is the member or the + * swarm. A `lost` end takes `member.end..lost` instead, so the engine's + * or the spawner's real end can still supersede it and take the plain one. + */ +export function markName(event: OnceEvent, id: string, lost = false): string { + return `${event}.${id}${lost ? ".lost" : ""}`; +} + +export function hasMark(homeDir: string, fleet: string, name: string): boolean { + return existsSync(join(marksDir(homeDir, fleet), name)); +} + +/** Take a marker with an exclusive create (0600 in a 0700 dir). False when another writer holds it. */ +export function claimMark(homeDir: string, fleet: string, name: string): boolean { + const dir = marksDir(homeDir, fleet); + mkdirPrivate(dir); + try { + writeFileSync(join(dir, name), "", { encoding: "utf8", flag: "wx", mode: FILE_MODE }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } +} + +/** + * Append a line only when its once-marker is free. Null means another writer + * already holds the marker: report "already" and write nothing. + */ +export function appendOnce( + homeDir: string, + fleet: string, + input: LedgerInput, + opts: { now?: Date; host?: string; once: string }, +): LedgerLine | null { + if (!claimMark(homeDir, fleet, opts.once)) return null; + return append(homeDir, fleet, input, { now: opts.now, host: opts.host }); +} + function parseLines(text: string): LedgerLine[] { const out: LedgerLine[] = []; for (const raw of text.split("\n")) { diff --git a/packages/openfleet/src/types.ts b/packages/openfleet/src/types.ts index 187f9b2..e5de817 100644 --- a/packages/openfleet/src/types.ts +++ b/packages/openfleet/src/types.ts @@ -178,7 +178,14 @@ export interface SwarmNode { task?: string; by: string; parent_swarm?: string; + /** The keys the spawner narrowed, as written in `swarm.spawn`. */ ceiling: Ceiling; + /** + * The swarm's effective ceiling: the fleet's merged down the swarm path + * with the latest caps last. In the implicit fleet `approvals` enters at + * each root and is not resolved here; fan_out, budget and until are. + */ + effective?: Ceiling; members: MemberNode[]; swarms: SwarmNode[]; state?: EndState; diff --git a/prd/0008-openfleet-reference-implementation.md b/prd/0008-openfleet-reference-implementation.md index 5279245..d44df60 100644 --- a/prd/0008-openfleet-reference-implementation.md +++ b/prd/0008-openfleet-reference-implementation.md @@ -74,7 +74,21 @@ the files exist and something writes them. - R2 [P0] `logicsrc fleet open|cap|tree|stop|log` with the spec's flags; `open` and `cap` exit 4 when `OPENFLEET_MEMBER` is set; `stop` exits 4 outside the caller's subtree; `stop` ends nested swarms first and writes one - `swarm.end` per swarm; every verb takes `--json`. + `swarm.end` per swarm, only once every member and every nested swarm has an + end line that counts, and exits non-zero when an engine would not end a + member; `cap` on a swarm refuses a key that would widen; every verb takes + `--json`. +- R2a [P0] Rule 6 lives in `tree`, run by the sysop: a working member past + its effective `until` is stopped through its engine and ends `timeout`; a + swarm or fleet whose summed `member.spend` in the budget's unit has reached + its budget has its members stopped, each ending `budget`; each swarm touched + gets its `swarm.end` when complete. An agent's `tree` stops nothing. +- R2b [P0] The effective ceiling is rebuilt from the ledger on every read: + the latest fleet-target `fleet.cap` (else `fleet.open`, else the implicit + fleet's) replaces the copy in a record, widening included; then each + `swarm.spawn` narrowing down the path, then swarm caps last. In the + implicit fleet a parentless record's own `approvals` enters at the root, and + the engine fills a ceiling a writer left without the key. - R3 [P0] `stop` goes through the member's own engine: `claude stop` for `claude-code`, `moshcode herd kill` for `moshcode/*`, `tmux kill-pane` for `tmux`, a signal for `claude-p`. Never a shell string. @@ -86,7 +100,9 @@ the files exist and something writes them. - R5 [P1] `tree` reads `claude agents --json --all` and `~/.moshcode/herd/sessions.json` when it can, draws recordless sessions as roots of the implicit fleet, and writes `member.end` state `lost` for a - recorded member its engine no longer lists. + recorded background job or pane its engine's roster can hold and no longer + lists. `claude agents` lists background jobs only, so an interactive or `-p` + session (a UUID member with no job id) is never marked lost by it. - R6 [P1] The spec and the landing page say what ships, keep `Status: 0.1`, and record the two verified Claude Code limits (no launcher environment reaches a dispatched background job; exported variables reach tools but not @@ -129,5 +145,10 @@ None. It is the reference implementation of an open standard. - User-level hooks fire for every `claude -p` a tool makes, so each becomes a swarm of one and, at depth 1 in the implicit fleet, is refused on depth. The spec lists this as an open question; the hooks enforce the letter of it. -- `hasEvent` before a write is a check, not a lock. Two writers racing on one - swarm can still produce two `swarm.end` lines. +- A ledger check before a write is not exclusion, so `member.start`, + `member.end` and `swarm.end` each take a once-marker first: an exclusive + create of `fleets//marks/.` (`.lost` suffixed for a lost + end, so a real end can still supersede it). moshcode uses the same paths. + A marker taken by a writer that then crashed before appending leaves the + line unwritten until someone clears the marker by hand; 0.1 accepts that + over a doubled audit line.