mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-05 06:05:28 +00:00
* OpenFleet reference implementation: @logicsrc/openfleet 0.1.0 and logicsrc fleet Ship what docs/openfleet.md describes. The new workspace package holds the record (write once, never overwrite, 0600), the ledger (append-only JSON Lines, merged across ledger*.jsonl by at), the ceiling rules (whole fleet ceiling, narrowed swarm keys, a merge that never widens, refusals by key), claiming and deriving exactly as the spec's "Claiming and deriving" and rule 13, and fold(), which turns any $OPENFLEET_HOME plus the engine rosters into the tree the landing page shows. logicsrc fleet open|cap|tree|stop|log are the sysop's verbs, every one with --json. open and cap exit 4 when OPENFLEET_MEMBER is set; stop exits 4 outside the caller's subtree, ends nested swarms first, goes through each member's own engine (claude stop, moshcode herd kill, tmux kill-pane, a signal for claude-p) and writes one swarm.end per swarm. tree reads claude agents --json --all and ~/.moshcode/herd/sessions.json when it can, draws recordless sessions as roster roots of the implicit fleet, and writes member.end lost for a recorded member its engine no longer lists. Claude Code takes part through hooks: logicsrc fleet hooks install merges SessionStart, UserPromptSubmit, PreToolUse, Stop and SessionEnd into ~/.claude/settings.json without clobbering it, and logicsrc fleet hook <Event> runs each one. SessionStart claims, derives or writes a root record and hands the member its variables through CLAUDE_ENV_FILE; UserPromptSubmit checks the ceiling with the permission mode the engine reports and writes member.start, or refuses the first prompt with exit 2 and ceiling.refuse; PreToolUse denies an edit outside piece.owns; Stop and SessionEnd write member.end. A hand-started root takes the engine's reported approvals before member.start, since the command line only guesses them. Hooks never fail the engine: everything is caught and logged to hooks.log. The spec and the landing page now say what ships, keep Status 0.1, and record the two verified Claude Code limits: a background job dispatched from claude agents gets no launcher environment, and OPENFLEET_* exported at SessionStart reach the member's tools but not later hooks, so hooks key on session_id through $OPENFLEET_HOME/sessions/<session_id>.json. PRD 0008 covers the work. CLI 0.2.1 -> 0.3.0; build and build:cli chains build the package before the CLI; README and docs/cli.md list the group. Tests: 95 in the package (record, ledger merge, every narrower case, the worked example's claim and derive, the folded tree, hook install idempotence, each hook handler including the exit-2 refusal and the PreToolUse deny, every verb with fake deps) and 4 in the CLI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV * 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 * openfleet hooks: no member.end for a member that never started A first prompt refused by the ceiling still lets the session wind down through Stop and SessionEnd; those handlers now write nothing when the ledger holds no member.start for the member, so a refused member is never drawn as done. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV * logicsrc-mcp test: the next free PRD id is 0009 now that PRD 0008 exists Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
485 lines
29 KiB
TypeScript
485 lines
29 KiB
TypeScript
import { readFileSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { Command } from "commander";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { EXIT, registerOpenFleetCommands, type Deps } from "./commands.js";
|
|
import { startMember } from "./context.js";
|
|
import { append, findEvents, readCurrent, readLedger, recordPath, writeRecord } from "./store.js";
|
|
import { DEV, FLEET, PIECE_1, PIECE_2, ROOT, cleanup, envFor, seedWorkedExample, seedWorkedExampleToEnd, tempHome } from "./test-helpers.js";
|
|
import type { RosterRow } from "./types.js";
|
|
|
|
const NOW = new Date("2026-09-13T06:00:00Z");
|
|
|
|
interface Harness {
|
|
run: (...argv: string[]) => Promise<number>;
|
|
out: string[];
|
|
err: string[];
|
|
raw: string[];
|
|
execs: string[][];
|
|
kills: number[];
|
|
deps: Deps;
|
|
}
|
|
|
|
function harness(home: string, extra: Partial<Deps> = {}, env: Record<string, string> = {}): Harness {
|
|
const out: string[] = [];
|
|
const err: string[] = [];
|
|
const raw: string[] = [];
|
|
const execs: string[][] = [];
|
|
const kills: number[] = [];
|
|
const deps: Deps = {
|
|
env: envFor(home, env),
|
|
now: () => NOW,
|
|
exec: async (file, args) => {
|
|
execs.push([file, ...args]);
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
},
|
|
kill: (pid) => {
|
|
kills.push(pid);
|
|
},
|
|
rosters: {},
|
|
stdin: async () => "",
|
|
write: (line) => out.push(line),
|
|
error: (line) => err.push(line),
|
|
stdout: (text) => raw.push(text),
|
|
stderr: (text) => raw.push(text),
|
|
host: "dev",
|
|
user: "anthony",
|
|
...extra,
|
|
};
|
|
const run = async (...argv: string[]): Promise<number> => {
|
|
const program = new Command();
|
|
program.name("logicsrc").enablePositionalOptions().exitOverride();
|
|
const fleet = program.command("fleet");
|
|
registerOpenFleetCommands(fleet, deps);
|
|
process.exitCode = 0;
|
|
await program.parseAsync(["node", "logicsrc", "fleet", ...argv]);
|
|
const code = Number(process.exitCode ?? 0);
|
|
process.exitCode = 0;
|
|
return code;
|
|
};
|
|
return { run, out, err, raw, execs, kills, deps };
|
|
}
|
|
|
|
describe("open", () => {
|
|
let home: string;
|
|
beforeEach(() => {
|
|
home = tempHome();
|
|
});
|
|
afterEach(() => {
|
|
cleanup(home);
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it("mints <name>-<yyyymmdd>, writes fleet.open by sysop with the ceiling from flags, writes current, prints the id", async () => {
|
|
const h = harness(home);
|
|
expect(await h.run("open", "team", "--approvals", "bypass", "--budget", "20 USD", "--depth", "2", "--fan-out", "4", "--hosts", "dev,netcup", "--until", "2h")).toBe(0);
|
|
expect(h.out).toEqual(["team-20260913"]);
|
|
expect(readCurrent(home)).toBe("team-20260913");
|
|
const lines = readLedger(home, "team-20260913");
|
|
expect(lines).toEqual([
|
|
{ at: "2026-09-13T06:00:00Z", event: "fleet.open", fleet: "team-20260913", host: "dev", by: "sysop", sysop: "anthony@dev", ceiling: { approvals: "bypass", budget: "20 USD", depth: 2, fan_out: 4, hosts: ["dev", "netcup"], until: "2026-09-13T08:00:00Z" } },
|
|
]);
|
|
// A second fleet the same day gets a suffix; a bare open uses "fleet" and this host.
|
|
expect(await h.run("open", "team", "--json")).toBe(0);
|
|
expect(JSON.parse(h.out[1])).toMatchObject({ fleet: "team-20260913-2", sysop: "anthony@dev", ceiling: { hosts: ["dev"] } });
|
|
expect(await h.run("open", "--sysop", "https://anthony.example/profile.md")).toBe(0);
|
|
expect(h.out[2]).toBe("fleet-20260913");
|
|
expect(readLedger(home, "fleet-20260913")[0].sysop).toBe("https://anthony.example/profile.md");
|
|
});
|
|
|
|
it("refuses with exit 4 when the caller carries OPENFLEET_MEMBER, and rejects bad flags with exit 2", async () => {
|
|
const agent = harness(home, {}, { OPENFLEET_MEMBER: "create-two-0541-1" });
|
|
expect(await agent.run("open")).toBe(EXIT.REFUSED);
|
|
expect(agent.err[0]).toMatch(/sysop's.*OPENFLEET_MEMBER=create-two-0541-1/);
|
|
expect(existsSync(join(home, "fleets"))).toBe(false);
|
|
const h = harness(home);
|
|
expect(await h.run("open", "--approvals", "sometimes")).toBe(EXIT.INVALID);
|
|
expect(await h.run("open", "--budget", "20")).toBe(EXIT.INVALID);
|
|
expect(await h.run("open", "--depth", "-1")).toBe(EXIT.INVALID);
|
|
expect(await h.run("open", "--until", "later")).toBe(EXIT.INVALID);
|
|
expect(existsSync(join(home, "fleets"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("cap", () => {
|
|
let home: string;
|
|
beforeEach(() => {
|
|
home = tempHome();
|
|
seedWorkedExampleToEnd(home);
|
|
});
|
|
afterEach(() => {
|
|
cleanup(home);
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it("narrows a running swarm and stops the bypass member now above the native ceiling, through its engine", async () => {
|
|
const h = harness(home);
|
|
expect(await h.run("cap", "create-two-0541", "--approvals", "native")).toBe(0);
|
|
const lines = readLedger(home, FLEET);
|
|
expect(findEvents(lines, "fleet.cap")[0]).toMatchObject({ by: "sysop", target: "create-two-0541", ceiling: { approvals: "native" } });
|
|
expect(h.execs).toEqual([["moshcode", "herd", "kill", "create-two-0541-2"]]);
|
|
expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "stopped" });
|
|
expect(h.out[0]).toBe('capped swarm create-two-0541: {"approvals":"native"}');
|
|
expect(h.out[1]).toBe("stopped create-two-0541-2 moshcode/claude stopped");
|
|
// The already-done member was left alone, and the root, outside the swarm, was not touched.
|
|
expect(findEvents(lines, "member.end", { member: "create-two-0541-1" }).length).toBe(1);
|
|
expect(findEvents(lines, "member.end", { member: "460a4502" })).toEqual([]);
|
|
});
|
|
|
|
it("sets a fleet's whole ceiling, refuses an agent, and says when the target does not exist", async () => {
|
|
const h = harness(home);
|
|
expect(await h.run("cap", FLEET, "--approvals", "native", "--json")).toBe(0);
|
|
const result = JSON.parse(h.out[0]);
|
|
expect(result).toMatchObject({ target: FLEET, kind: "fleet", ceiling: { approvals: "native", hosts: ["dev"] } });
|
|
// The root ran with bypass; under a native fleet ceiling it is stopped through claude.
|
|
expect(h.execs).toContainEqual(["claude", "stop", "460a4502"]);
|
|
expect(result.stopped.map((row: { member: string }) => row.member)).toContain("460a4502");
|
|
expect(await h.run("cap", "nope", "--depth", "1")).toBe(EXIT.NOT_FOUND);
|
|
expect(await h.run("cap", "create-two-0541")).toBe(EXIT.INVALID);
|
|
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", () => {
|
|
let home: string;
|
|
beforeEach(() => {
|
|
home = tempHome();
|
|
seedWorkedExampleToEnd(home);
|
|
});
|
|
afterEach(() => {
|
|
cleanup(home);
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it("prints the tree, or JSON, and writes member.end lost for a recorded member its engine no longer lists", async () => {
|
|
const h = harness(home);
|
|
expect(await h.run("tree")).toBe(0);
|
|
expect(h.out[0].split("\n")[0]).toBe("anthony@dev (implicit fleet, sysop anthony@dev, depth 1, hosts dev)");
|
|
expect(h.out[0]).toContain("swarm create-two-0541");
|
|
expect(await h.run("tree", "--json")).toBe(0);
|
|
expect(JSON.parse(h.out[1]).fleets[0].roots[0].member).toBe("460a4502");
|
|
expect(await h.run("tree", "nope")).toBe(EXIT.NOT_FOUND);
|
|
|
|
const moshcode = async (): Promise<RosterRow[]> => [];
|
|
const rostered = harness(home, { rosters: { moshcode } });
|
|
expect(await rostered.run("tree")).toBe(0);
|
|
expect(rostered.out[0]).toMatch(/create-two-0541-2 +create bye.sh bash +moshcode\/claude +lost/);
|
|
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "lost" });
|
|
// As an agent, the lost line is by that agent.
|
|
const asAgent = harness(home, { rosters: { claude: async () => [] } }, { OPENFLEET_MEMBER: "create-two-0541-1" });
|
|
expect(await asAgent.run("tree")).toBe(0);
|
|
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ by: "create-two-0541-1", state: "lost" });
|
|
// --no-roster reads nothing and writes nothing.
|
|
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", () => {
|
|
let home: string;
|
|
beforeEach(() => {
|
|
home = tempHome();
|
|
seedWorkedExampleToEnd(home);
|
|
});
|
|
afterEach(() => {
|
|
cleanup(home);
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it("ends a swarm as one unit: nested swarms first, members through their engines, then one swarm.end", async () => {
|
|
// A nested swarm spawned by the second piece, with a claude-p member and a tmux member.
|
|
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" }, { member: "nested-0550-2" }] }, { host: "dev" });
|
|
append(home, FLEET, { at: "2026-09-13T05:50:01Z", event: "member.start", by: "nested-0550-1", member: "nested-0550-1", session: "31337", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "claude-p", approvals: "native" }, { host: "dev" });
|
|
append(home, FLEET, { at: "2026-09-13T05:50:02Z", event: "member.start", by: "nested-0550-2", member: "nested-0550-2", session: "%7", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "tmux", approvals: "native" }, { host: "dev" });
|
|
const h = harness(home);
|
|
expect(await h.run("stop", "create-two-0541")).toBe(0);
|
|
expect(h.kills).toEqual([31337]);
|
|
expect(h.execs).toEqual([
|
|
["tmux", "-L", "moshcode", "kill-pane", "-t", "%7"],
|
|
["moshcode", "herd", "kill", "create-two-0541-2"],
|
|
]);
|
|
const lines = readLedger(home, FLEET);
|
|
const events = lines.filter((line) => Date.parse(line.at) >= NOW.getTime()).map((line) => `${line.event} ${line.member ?? line.swarm} ${line.state} ${line.by}`);
|
|
expect(events).toEqual([
|
|
"member.end nested-0550-1 stopped sysop",
|
|
"member.end nested-0550-2 stopped sysop",
|
|
"swarm.end nested-0550 stopped sysop",
|
|
"member.end create-two-0541-2 stopped sysop",
|
|
"swarm.end create-two-0541 stopped sysop",
|
|
]);
|
|
expect(h.out).toEqual([
|
|
"stopped nested-0550-1 claude-p stopped",
|
|
"stopped nested-0550-2 tmux stopped",
|
|
"ended swarm nested-0550 stopped",
|
|
"skipped create-two-0541-1 claude-code done (already ended)",
|
|
"stopped create-two-0541-2 moshcode/claude stopped",
|
|
"ended swarm create-two-0541 stopped",
|
|
]);
|
|
// Stopping again touches nothing: one swarm.end per swarm, no end line for an ended member.
|
|
const again = harness(home);
|
|
expect(await again.run("stop", "create-two-0541", "--json")).toBe(0);
|
|
expect(again.execs).toEqual([]);
|
|
expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: "create-two-0541" }).length).toBe(1);
|
|
expect(JSON.parse(again.out[0]).swarms).toEqual([{ swarm: "nested-0550", state: "stopped", ended: false, note: "already ended" }, { swarm: "create-two-0541", state: "stopped", ended: false, note: "already ended" }]);
|
|
});
|
|
|
|
it("stops one member through claude, by the caller, and reports an engine that says no", async () => {
|
|
const h = harness(home);
|
|
expect(await h.run("stop", "460a4502")).toBe(0);
|
|
expect(h.execs).toEqual([["claude", "stop", "460a4502"]]);
|
|
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ by: "sysop", state: "stopped" });
|
|
const failing = harness(home, { exec: async () => ({ code: 1, stdout: "", stderr: "no such session" }) });
|
|
expect(await failing.run("stop", "create-two-0541-2")).toBe(EXIT.NOT_FOUND);
|
|
expect(failing.out[0]).toContain("no such session");
|
|
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })).toEqual([]);
|
|
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);
|
|
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })[0].by).toBe("460a4502");
|
|
const sibling = harness(home, {}, { OPENFLEET_MEMBER: "create-two-0541-1" });
|
|
expect(await sibling.run("stop", "create-two-0541")).toBe(EXIT.REFUSED);
|
|
expect(sibling.err[0]).toMatch(/outside the subtree create-two-0541-1 spawned/);
|
|
expect(await sibling.run("stop", "460a4502")).toBe(EXIT.REFUSED);
|
|
expect(await sibling.run("stop", FLEET, "--fleet")).toBe(EXIT.REFUSED);
|
|
expect(sibling.execs).toEqual([]);
|
|
});
|
|
|
|
it("--fleet stops every swarm and every root", async () => {
|
|
const h = harness(home);
|
|
expect(await h.run("stop", FLEET, "--fleet")).toBe(0);
|
|
expect(h.execs).toEqual([
|
|
["moshcode", "herd", "kill", "create-two-0541-2"],
|
|
["claude", "stop", "460a4502"],
|
|
]);
|
|
const lines = readLedger(home, FLEET);
|
|
expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })[0]).toMatchObject({ state: "stopped" });
|
|
expect(findEvents(lines, "member.end", { member: "460a4502" })[0]).toMatchObject({ state: "stopped", by: "sysop" });
|
|
});
|
|
});
|
|
|
|
describe("log", () => {
|
|
let home: string;
|
|
beforeEach(() => {
|
|
home = tempHome();
|
|
seedWorkedExampleToEnd(home);
|
|
});
|
|
afterEach(() => {
|
|
cleanup(home);
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it("prints one line per event, or JSON Lines, filtered by member, swarm and since", async () => {
|
|
const h = harness(home);
|
|
expect(await h.run("log")).toBe(0);
|
|
expect(h.out.length).toBe(9);
|
|
expect(h.out[0]).toMatch(/^2026-09-13T04:55:01Z {2}member.start {4}460a4502 +member 460a4502 engine claude-code approvals bypass$/);
|
|
expect(h.out[8]).toMatch(/^2026-09-13T05:41:36Z {2}member.end {6}create-two-0541-1 +member create-two-0541-1 done Created hello.sh, mode -rwxrwxr-x, prints hello.$/);
|
|
expect(h.out[5]).toContain('swarm create-two-0541 task create two ... pieces 2 narrowed {"fan_out":4,"until":"2026-09-13T06:11:01Z"}');
|
|
|
|
const json = harness(home);
|
|
expect(await json.run("log", FLEET, "--json", "--member", "create-two-0541-1")).toBe(0);
|
|
expect(json.out.map((line) => JSON.parse(line).event)).toEqual(["member.start", "member.end"]);
|
|
|
|
const swarm = harness(home);
|
|
expect(await swarm.run("log", "--swarm", "create-two-0541")).toBe(0);
|
|
expect(swarm.out.map((line) => line.split(/ {2}/)[1].trim())).toEqual(["swarm.spawn", "member.start", "member.start", "member.end"]);
|
|
|
|
const since = harness(home);
|
|
expect(await since.run("log", "--since", "2026-09-13T05:41:30Z")).toBe(0);
|
|
expect(since.out.length).toBe(1);
|
|
expect(await since.run("log", "--since", "1h")).toBe(0);
|
|
expect(since.out.length).toBe(1 + 8);
|
|
expect(await since.run("log", "--since", "whenever")).toBe(EXIT.INVALID);
|
|
expect(await since.run("log", "nope")).toBe(EXIT.NOT_FOUND);
|
|
const empty = harness(tempHome());
|
|
expect(await empty.run("log")).toBe(0);
|
|
expect(empty.out).toEqual(["(no events)"]);
|
|
});
|
|
});
|
|
|
|
describe("hooks and hook", () => {
|
|
let home: string;
|
|
beforeEach(() => {
|
|
home = tempHome();
|
|
});
|
|
afterEach(() => {
|
|
cleanup(home);
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it("installs, reports and removes over --settings-file, never the real one", async () => {
|
|
const file = join(home, "settings.json");
|
|
const h = harness(home);
|
|
expect(await h.run("hooks", "status", "--settings-file", file)).toBe(0);
|
|
expect(h.out[0]).toBe(`${file}: not installed`);
|
|
expect(await h.run("hooks", "install", "--settings-file", file)).toBe(0);
|
|
expect(h.out[6]).toBe(`wrote 5 hooks in ${file}`);
|
|
expect(JSON.parse(readFileSync(file, "utf8")).hooks.SessionStart[0].hooks[0].command).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook SessionStart; exit 0");
|
|
expect(await h.run("hooks", "install", "--settings-file", file, "--json")).toBe(0);
|
|
expect(JSON.parse(h.out[12]).written).toBe(0);
|
|
expect(await h.run("hooks", "status", "--settings-file", file)).toBe(0);
|
|
expect(h.out[13]).toBe(`${file}: installed`);
|
|
expect(await h.run("hooks", "remove", "--settings-file", file)).toBe(0);
|
|
expect(h.out[19]).toBe(`removed 5 hooks from ${file}`);
|
|
// With HOME pointed at the temp dir, the default file lands there too.
|
|
expect(await h.run("hooks", "install", "--dry-run")).toBe(0);
|
|
expect(h.out[20]).toBe(`would write 5 hooks in ${join(home, ".claude", "settings.json")}`);
|
|
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
|
|
});
|
|
|
|
it("runs a hook over stdin and writes its verdict raw", async () => {
|
|
seedWorkedExample(home);
|
|
const sessionId = "68aca9c1-1111-4222-8333-444455556666";
|
|
const start = harness(home, {
|
|
stdin: async () => JSON.stringify({ session_id: sessionId, cwd: "/x", source: "startup" }),
|
|
hookIo: { readProcCmdline: () => ["claude"], readProcEnviron: () => ({}), appendFile: () => undefined },
|
|
}, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1"), CLAUDE_PID: "1" });
|
|
expect(await start.run("hook", "SessionStart")).toBe(0);
|
|
expect(start.raw).toEqual(["OpenFleet: you are member create-two-0541-1 of fleet anthony@dev, piece create hello.sh bash, owns hello.sh\n"]);
|
|
const deny = harness(home, { stdin: async () => JSON.stringify({ session_id: sessionId, cwd: "/x", tool_name: "Write", tool_input: { file_path: "bye.sh" } }) });
|
|
expect(await deny.run("hook", "PreToolUse")).toBe(0);
|
|
expect(JSON.parse(deny.raw[0]).hookSpecificOutput.permissionDecision).toBe("deny");
|
|
// A refused start comes back as exit 2 with the reason on stderr.
|
|
writeRecord(home, { ...ROOT, member: "native-root", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } });
|
|
startMember(home, { ...ROOT, member: "native-root", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } }, { sessionId: "native-root", approvals: "native", now: NOW, host: "dev", implicit: DEV });
|
|
const child = "cafe0000-1111-4222-8333-444455556666";
|
|
const refused = harness(home, {
|
|
stdin: async () => JSON.stringify({ session_id: child, cwd: "/x", source: "startup" }),
|
|
hookIo: { readProcCmdline: () => ["claude", "--dangerously-skip-permissions"], readProcEnviron: () => ({}), appendFile: () => undefined },
|
|
}, { OPENFLEET_RECORD: recordPath(home, FLEET, "native-root"), CLAUDE_PID: "1" });
|
|
expect(await refused.run("hook", "SessionStart")).toBe(0);
|
|
expect(refused.raw[0]).toBe("OpenFleet: start refused, ceiling refuses approvals: wanted bypass, allowed native\n");
|
|
const prompt = harness(home, { stdin: async () => JSON.stringify({ session_id: child, cwd: "/x", permission_mode: "bypassPermissions", prompt: "go" }) });
|
|
expect(await prompt.run("hook", "UserPromptSubmit")).toBe(2);
|
|
expect(prompt.raw[0]).toBe("OpenFleet refused the start: ceiling refuses approvals: wanted bypass, allowed native\n");
|
|
void PIECE_2;
|
|
});
|
|
});
|