OpenFleet fix round: rebuild the ceiling from the ledger, once-markers, rule 6 in tree, lost only for what a roster can hold

The review of the reference implementation against moshcode found the two
readers disagreeing on the same files. This round applies the shared
rulings so both sides read a ledger the same way.

Ceiling (R-A, R-B, R-C, R1, R6, R10, R15, R17): memberCeiling rebuilds the
effective ceiling 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, so a sysop's widening cap reaches running members; 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; a ceiling a
writer left without the key is never read as native, and startMember fills
it with the engine's word while the record is unclaimed. A fleet.open or
cap with no hosts means the host it was written on (R23).

Once-markers (R-G, R28): member.start, member.end and swarm.end each take
an exclusive create under fleets/<fleet>/marks/<event>.<id> before the
append; a lost end takes <id>.lost so a real end can still supersede it.
The hooks let a real end follow a lost line (R9).

tree (R-F, R20): run by the sysop it enforces rule 6, stopping a member
past its effective until with state timeout and the members of a swarm or
fleet at its budget with state budget, then writes swarm.end for each swarm
touched once it is complete. An agent's tree stops nothing. lost is written
only for a member its engine's roster can hold: a claude-code background job
(8-hex member or session) or a moshcode pane, never an interactive session
claude agents does not list (R-E, R3, R14). A nested swarm is drawn under
the member that spawned it and its row shows the effective ceiling (R25).

stop and cap (R-D, R-H, R22, R27): swarm.end is written only once every
member and every nested swarm has an end line that counts; an engine that
will not end a member leaves it without an end line and the verb exits
non-zero. claude stop takes the job id: the member of a background job,
else the first eight characters of a session UUID; an interactive session
with no job id cannot be stopped and the tool says so. cap on a swarm
refuses a key that would widen. A derived claude-code job is named by its
job id and carries no pid.

Also: R-I (endMember ends only the engine-minted swarm of one), R35 (a
derived record's guessed approvals corrected at UserPromptSubmit), R32
(the UserPromptSubmit hook passes only exit 2 through), R31 (package
README), R36 (rule 13 says the launcher test is unimplemented in 0.1),
docs and PRD 0008 updated for lost, rule 6 and the markers. 113 openfleet
tests, 93 CLI tests, contract green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV
This commit is contained in:
Anthony Ettinger 2026-09-13 10:45:30 +00:00
parent 4ceaaaaa1f
commit ce8fc7c153
20 changed files with 962 additions and 139 deletions

View file

@ -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", () => {

View file

@ -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. */

View file

@ -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);

View file

@ -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<StopRow, "kind">[]; swarms: Omit<S
return { members, swarms };
}
/** Claude Code's roster keys jobs by the first eight characters of the session id. */
function claudeHandle(handle: string): string {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(handle) ? handle.slice(0, 8) : handle;
}
async function stopThroughEngine(deps: Deps, record: Partial<FleetRecord> & { member: string }): Promise<string | null> {
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<FleetRecord> & { 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<FleetRecord> & { 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<string, FleetRecord>): 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<void> {
/**
* 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<void> {
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<void> {
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<string>(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<void> {
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<string, number>): 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<void> {
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<string, SwarmNode>(flattenSwarms(tree).filter((entry) => entry.fleet === fleet.fleet).map((entry) => [entry.swarm.swarm, entry.swarm]));
const touched = new Set<string>();
const stopAs = async (node: MemberNode, state: EndState): Promise<void> => {
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<Deps> =
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<Deps> =
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<Deps> =
!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<Deps> =
.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<Deps> =
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<Deps> =
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<Deps> =
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<Deps> =
}
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));
}),
);

View file

@ -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 <parent>-<n>. 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;

View file

@ -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.<id>.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 <parent>-<n>. 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) },
);
}
}

View file

@ -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);
});
});

View file

@ -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<string, { swarm: string; title?: string; owns?: string[]; task?: string }>();
const swarms = new Map<string, SwarmNode>();
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(" ") };
}

View file

@ -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]);

View file

@ -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`;
}

View file

@ -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", () => {

View file

@ -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(

View file

@ -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;
}

View file

@ -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");

View file

@ -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/<fleet>/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: `<event>.<id>`, where id is the member or the
* swarm. A `lost` end takes `member.end.<id>.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")) {

View file

@ -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;