git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
353 lines
19 KiB
JavaScript
353 lines
19 KiB
JavaScript
// Compaction counts on a real run (row 270, 264c, #1775, T3's Pi half): the
|
|
// launcher collects a thread session's completed compactions into its
|
|
// store's ledger at run start and at run end, and session.list reads the
|
|
// ledger through the runtime's reader. The count holds across a resume and a
|
|
// host restart. Real runners in the bwrap sandbox; a stub Pi adapter writes
|
|
// the session stream (fixtures/compaction-adapter.mjs).
|
|
//
|
|
// Round 2 (duckworth-0223 B2, sage-3031, mos-1352): a failed collection, the
|
|
// read budget from the system config, a clear's retired session and its
|
|
// successor, and a legacy session read through the runtime's reader. Round 3
|
|
// (duckworth-0226 B4, sage-3038): the stream cap from the system config.
|
|
//
|
|
// The host is session-launch.test.mjs's: startBroker on bootConfig's config,
|
|
// with the ops the broker child forwards, and the human capability from
|
|
// bindHuman directly.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { launchDoc } from "../../organization/tests/helpers.mjs";
|
|
import { BusError } from "../../bus/src/broker.mjs";
|
|
import { startBroker } from "../../bus/src/runtime.mjs";
|
|
import { Store } from "../../bus/src/store.mjs";
|
|
import { collectCompactions, LEDGER } from "../../seat/src/compactions.mjs";
|
|
import { gitEnv } from "../../seat/src/checkout.mjs";
|
|
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
|
import { launchLogFile, sessionStore } from "../../seat/src/session.mjs";
|
|
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
|
|
import { bootConfig, loadSystem } from "../src/config.mjs";
|
|
import { createLauncher } from "../src/launcher.mjs";
|
|
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
|
|
|
const ADAPTER = fileURLToPath(new URL("./fixtures/compaction-adapter.mjs", import.meta.url));
|
|
const skip = sandboxProblem() ?? false;
|
|
|
|
async function until(fn, ms = 20000) {
|
|
const end = Date.now() + ms;
|
|
while (Date.now() < end) {
|
|
const v = await fn();
|
|
if (v) return v;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
throw new Error("timed out waiting");
|
|
}
|
|
// A mutant's kill is an assertion failure, not a timeout (sage-2707), so a wait a mutant could
|
|
// stall returns what it last saw and the assertions after it decide.
|
|
async function settle(fn, ms = 5000) {
|
|
const end = Date.now() + ms;
|
|
let v;
|
|
while (Date.now() < end) {
|
|
v = await fn();
|
|
if (v) return v;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function gitIn(cwd, ...args) {
|
|
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
}
|
|
|
|
// The assistant is the thread instance, on Pi.
|
|
function prepare(t) {
|
|
const root = tmp(t, "mosaic-compactions-");
|
|
const f = fixture(root, "acme", (doc) => {
|
|
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
|
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
|
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet: 3 });
|
|
doc.launch.threadIdleSeconds = 1;
|
|
return doc;
|
|
});
|
|
withoutHostCredentials(f.env, { piKey: true });
|
|
const project = f.doc.projects.stack.root;
|
|
gitIn(project, "init", "-q", "-b", "main");
|
|
writeFileSync(join(project, "file.txt"), "one\n");
|
|
gitIn(project, "add", "file.txt");
|
|
gitIn(project, "commit", "-q", "-m", "one");
|
|
const adapter = join(root, "adapter.sh");
|
|
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
|
|
return { root, f, adapter };
|
|
}
|
|
|
|
// A host and its launcher on the prepared root.
|
|
async function start(t, { f, adapter }) {
|
|
const system = loadSystem({ env: f.env });
|
|
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
|
|
assert.equal(trackers, undefined);
|
|
const runtime = await startBroker(boot);
|
|
const ops = {
|
|
'request.admission': m => runtime.admission(m),
|
|
identity: (m) => runtime.identity(m.cap),
|
|
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
|
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
|
endLaunch: (m) => runtime.endLaunch(m.record),
|
|
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
|
threadsState: (m) => runtime.threadsState(m.business),
|
|
threadIdle: (m) => runtime.threadIdle(m.record),
|
|
mayLaunch: (m) => runtime.mayLaunch(m),
|
|
landHeld: (m) => runtime.landHeld(m.record),
|
|
landHolders: (m) => runtime.landHolders(m.business),
|
|
landRelease: (m) => runtime.landRelease(m.record),
|
|
sessionClears: (m) => runtime.sessionClears(m.business),
|
|
sessionClearExited: (m) => runtime.sessionClearExited(m.record),
|
|
sessionClearUncertain: (m) => runtime.sessionClearUncertain(m.record),
|
|
sessionClearRetire: (m) => runtime.sessionClearRetire(m.record),
|
|
sessionClearStart: (m) => runtime.sessionClearStart(m.record),
|
|
};
|
|
const refusing = (what, fn) => async (m) => {
|
|
try {
|
|
return fn(m);
|
|
} catch (e) {
|
|
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
|
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
|
}
|
|
};
|
|
const hooks = [];
|
|
let closing = null;
|
|
const host = {
|
|
business: "acme",
|
|
path: runtime.path,
|
|
dataRoot: f.dataRoot,
|
|
op: refusing("launch-op", (m) => {
|
|
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
|
return ops[m.op](m);
|
|
}),
|
|
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
|
beforeClose: (fn) => void hooks.push(fn),
|
|
};
|
|
const logs = [];
|
|
const launcher = createLauncher({
|
|
host,
|
|
system,
|
|
env: f.env,
|
|
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
|
sessionDefaults: { pollInterval: 100 },
|
|
harnessVersion: "0.85.1",
|
|
readOnly: [ADAPTER],
|
|
threadTickMs: 100,
|
|
log: (l) => logs.push(l),
|
|
});
|
|
const close = () =>
|
|
(closing ??= (async () => {
|
|
for (const fn of hooks) await fn(0);
|
|
await runtime.close();
|
|
})());
|
|
t.after(close);
|
|
await launcher.listen();
|
|
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
|
|
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
|
return { runtime, launcher, call, logs, close };
|
|
}
|
|
|
|
const launchLog = (f) =>
|
|
existsSync(launchLogFile(f.dataRoot, "acme"))
|
|
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
|
|
: [];
|
|
const events = (f, event) => launchLog(f).filter((e) => e.event === event);
|
|
const counted = (f) => events(f, "compactions").map((e) => [e.session, e.count, e.known, e.reason]);
|
|
|
|
// Send to a thread's default session and wait for the assistant's RESULT reply.
|
|
async function ask(call, thread, body) {
|
|
const sent = call("thread.send", { thread, body });
|
|
const r = await until(() => call("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
|
|
assert.equal(r.from_role, "assistant");
|
|
return JSON.parse(r.body.match(/THREAD (\{.*\})/)[1]);
|
|
}
|
|
|
|
const line = (e) => `${JSON.stringify(e)}\n`;
|
|
|
|
test("REQ-THREAD-2 T3 a run collects compactions at its start and its end, and the count holds across a resume and a host restart", { skip, timeout: 90000 }, async (t) => {
|
|
const prepared = prepare(t);
|
|
const { f } = prepared;
|
|
let host = await start(t, prepared);
|
|
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
|
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
|
const [{ id: D }] = host.call("session.list", { thread: A });
|
|
// An earlier run left a compaction no run-end collection saw (a host that died mid-run).
|
|
// Only the run-start collection counts it before the agent's first turn.
|
|
const store = sessionStore(f.dataRoot, "acme", A, D);
|
|
writeFileSync(join(store.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c0", parentId: null, timestamp: "2026-10-22T10:59:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
|
|
|
|
// Run 1: start counts c0 (M13), the turn compacts c1, the end counts it (M14).
|
|
assert.deepEqual(await ask(host.call, A, "compact c1"), { body: "compact c1", before: [] });
|
|
await until(() => events(f, "end").length === 1, 30000);
|
|
await settle(() => counted(f).length >= 2);
|
|
assert.deepEqual(counted(f), [[D, 1, true, null], [D, 2, true, null]]);
|
|
|
|
// A restart: a new host and launcher on the same data root. Run 2 resumes the session.
|
|
await host.close();
|
|
host = await start(t, prepared);
|
|
assert.deepEqual(await ask(host.call, A, "compact c2"), { body: "compact c2", before: ["compact c1"] });
|
|
await until(() => events(f, "end").length === 2, 30000);
|
|
await settle(() => counted(f).length >= 4);
|
|
assert.deepEqual(counted(f), [[D, 1, true, null], [D, 2, true, null], [D, 2, true, null], [D, 3, true, null]]);
|
|
|
|
// session.list reads the ledger through the runtime's reader (M15), after another restart.
|
|
await host.close();
|
|
host = await start(t, prepared);
|
|
const [row] = host.call("session.list", { thread: A });
|
|
assert.equal(row.id, D);
|
|
assert.deepEqual({ ...row.compactions, at: typeof row.compactions?.at }, { count: 3, known: true, reason: null, at: "string" });
|
|
});
|
|
|
|
test("REQ-THREAD-2 T3 a collection that fails is logged and recorded store-unreadable, and the run ends as one whose collections count", { skip, timeout: 90000 }, async (t) => {
|
|
const prepared = prepare(t);
|
|
const { f } = prepared;
|
|
const host = await start(t, prepared);
|
|
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
|
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
|
const [{ id: D }] = host.call("session.list", { thread: A });
|
|
// The ledger's path is a directory, so readLedger refuses it (ledger-unsafe).
|
|
const store = sessionStore(f.dataRoot, "acme", A, D);
|
|
mkdirSync(join(store.dir, LEDGER));
|
|
assert.deepEqual(await ask(host.call, A, "compact c1"), { body: "compact c1", before: [] });
|
|
await until(() => events(f, "end").length === 1, 30000);
|
|
await settle(() => counted(f).length >= 2);
|
|
assert.deepEqual(counted(f), [[D, null, false, "store-unreadable"], [D, null, false, "store-unreadable"]]);
|
|
const [{ run: first }] = events(f, "launch");
|
|
assert.equal(host.logs.filter((l) => l.startsWith(`run ${first}: compactions: `)).length, 2);
|
|
// With the ledger's path free again, the next run's collections count what the stream holds.
|
|
rmSync(join(store.dir, LEDGER), { recursive: true });
|
|
assert.deepEqual(await ask(host.call, A, "compact c2"), { body: "compact c2", before: ["compact c1"] });
|
|
await until(() => events(f, "end").length === 2, 30000);
|
|
await settle(() => counted(f).length >= 4);
|
|
assert.deepEqual(counted(f).slice(2), [[D, 1, true, null], [D, 2, true, null]]);
|
|
const ends = events(f, "end").map((e) => [e.reason, e.exitCode, e.signal]);
|
|
assert.deepEqual(ends[0], ends[1]);
|
|
});
|
|
|
|
test("REQ-THREAD-2 T3 the read budget is the system config's compactionReadBytes, and a bad value takes the default", { skip, timeout: 90000 }, async (t) => {
|
|
const prepared = prepare(t);
|
|
const { f } = prepared;
|
|
const setting = (v) => {
|
|
const doc = JSON.parse(readFileSync(f.config, "utf8"));
|
|
doc.compactionReadBytes = v;
|
|
writeFileSync(f.config, JSON.stringify(doc));
|
|
};
|
|
setting(1 << 20);
|
|
let host = await start(t, prepared);
|
|
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
|
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
|
const [{ id: D }] = host.call("session.list", { thread: A });
|
|
// c0, then a 2 MiB hole with no newline: a line past 1 MiB, inside the 64 MiB default.
|
|
const store = sessionStore(f.dataRoot, "acme", A, D);
|
|
const path = join(store.session, "pi.jsonl");
|
|
const whole = line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c0", parentId: null, timestamp: "2026-10-22T10:59:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 });
|
|
writeFileSync(path, whole, { mode: 0o600 });
|
|
truncateSync(path, Buffer.byteLength(whole) + 2 * (1 << 20));
|
|
assert.deepEqual(await ask(host.call, A, "one"), { body: "one", before: [] });
|
|
await until(() => events(f, "end").length === 1, 30000);
|
|
await settle(() => counted(f).length >= 2);
|
|
assert.deepEqual(counted(f), [[D, 1, false, "not-collected"], [D, 1, false, "not-collected"]]);
|
|
assert.deepEqual(host.logs.filter((l) => l.startsWith("system config: ")), []);
|
|
|
|
setting("x");
|
|
await host.close();
|
|
host = await start(t, prepared);
|
|
assert.deepEqual(host.logs.filter((l) => l.startsWith("system config: ")), [
|
|
'system config: compactionReadBytes "x" isn\'t a whole number of bytes from 1048576 to 1073741824; compaction collections read up to the default 67108864',
|
|
]);
|
|
assert.deepEqual(await ask(host.call, A, "two"), { body: "two", before: ["one"] });
|
|
await until(() => events(f, "end").length === 2, 30000);
|
|
await settle(() => counted(f).length >= 4);
|
|
assert.deepEqual(counted(f).slice(2), [[D, 1, true, null], [D, 1, true, null]]);
|
|
});
|
|
|
|
test("REQ-THREAD-2 T3 the stream cap is the system config's compactionStreams, and a bad value takes the default", { skip, timeout: 90000 }, async (t) => {
|
|
const prepared = prepare(t);
|
|
const { f } = prepared;
|
|
const setting = (v) => {
|
|
const doc = JSON.parse(readFileSync(f.config, "utf8"));
|
|
doc.compactionStreams = v;
|
|
writeFileSync(f.config, JSON.stringify(doc));
|
|
};
|
|
setting(32);
|
|
let host = await start(t, prepared);
|
|
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
|
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
|
const [{ id: D }] = host.call("session.list", { thread: A });
|
|
// 33 empty streams that sort before pi.jsonl: 34 in all, past a cap of 32, so pi.jsonl and its
|
|
// c0 aren't opened. The default cap opens all 34.
|
|
const store = sessionStore(f.dataRoot, "acme", A, D);
|
|
for (let i = 0; i < 33; i++) writeFileSync(join(store.session, `e${String(i).padStart(2, "0")}.jsonl`), "", { mode: 0o600 });
|
|
writeFileSync(join(store.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c0", parentId: null, timestamp: "2026-10-22T10:59:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
|
|
assert.deepEqual(await ask(host.call, A, "one"), { body: "one", before: [] });
|
|
await until(() => events(f, "end").length === 1, 30000);
|
|
await settle(() => counted(f).length >= 2);
|
|
assert.deepEqual(counted(f), [[D, null, false, "not-collected"], [D, null, false, "not-collected"]]);
|
|
assert.deepEqual(host.logs.filter((l) => l.startsWith("system config: ")), []);
|
|
|
|
setting("x");
|
|
await host.close();
|
|
host = await start(t, prepared);
|
|
assert.deepEqual(host.logs.filter((l) => l.startsWith("system config: ")), [
|
|
'system config: compactionStreams "x" isn\'t a whole number from 32 to 65536; compaction collections open up to the default 1024 streams',
|
|
]);
|
|
assert.deepEqual(await ask(host.call, A, "two"), { body: "two", before: ["one"] });
|
|
await until(() => events(f, "end").length === 2, 30000);
|
|
await settle(() => counted(f).length >= 4);
|
|
assert.deepEqual(counted(f).slice(2), [[D, 1, true, null], [D, 1, true, null]]);
|
|
});
|
|
|
|
test("REQ-THREAD-2 T3 T4 a clear leaves the retired session's count as it was, its successor counts from 0, and a legacy session reads the thread's ledger", { skip, timeout: 90000 }, async (t) => {
|
|
const prepared = prepare(t);
|
|
const { f } = prepared;
|
|
let host = await start(t, prepared);
|
|
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
|
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
|
const [{ id: D }] = host.call("session.list", { thread: A });
|
|
assert.deepEqual(await ask(host.call, A, "compact c1"), { body: "compact c1", before: [] });
|
|
await until(() => events(f, "end").length === 1, 30000);
|
|
await settle(() => counted(f).length >= 2);
|
|
assert.deepEqual(counted(f), [[D, 0, true, null], [D, 1, true, null]]);
|
|
|
|
// The run has ended, so the clear retires D at once and starts the successor, whose run-start
|
|
// collection is its first.
|
|
const S = host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" }).successor;
|
|
const done = await settle(() => {
|
|
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
|
return e.start === "started" && e;
|
|
}, 30000);
|
|
assert.deepEqual([done?.state, done?.start], ["retired", "started"]);
|
|
await settle(() => counted(f).some(([s]) => s === S));
|
|
assert.deepEqual(counted(f).filter(([s]) => s === S)[0], [S, 0, true, null]);
|
|
const list = () => Object.fromEntries(host.call("session.list", { thread: A }).map((r) => [r.id, r.compactions && { count: r.compactions.count, known: r.compactions.known, reason: r.compactions.reason }]));
|
|
assert.deepEqual(list(), { [D]: { count: 1, known: true, reason: null }, [S]: { count: 0, known: true, reason: null } });
|
|
|
|
// A legacy session (op legacy-3l, from the 3k-3l migration): its store is the thread's, made by
|
|
// hand as packages/bus/tests/session-compactions.test.mjs does, with a ledger the launcher's
|
|
// collect wrote.
|
|
await until(() => events(f, "end").length === 2, 30000);
|
|
await host.close();
|
|
const L = "4b1c3a52-9a51-4d7e-8f2e-6c0a1b2d3e4f";
|
|
const seeded = new Store(f.dataRoot);
|
|
const at = "2026-10-21T00:00:00.000Z";
|
|
seeded.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", L, at, "acme", project, "assistant", f.doc.human, "legacy");
|
|
seeded.run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?", L);
|
|
seeded.run("INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?", L);
|
|
seeded.close();
|
|
const legacy = sessionStore(f.dataRoot, "acme", L, L, { legacy: true });
|
|
writeFileSync(join(legacy.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c7", parentId: null, timestamp: "2026-10-22T11:07:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
|
|
assert.equal(collectCompactions(legacy, { name: "pi", compactions: pi }).count, 1);
|
|
host = await start(t, prepared);
|
|
const [row] = host.call("session.list", { thread: L });
|
|
assert.equal(row.id, L);
|
|
assert.deepEqual({ ...row.compactions, at: typeof row.compactions?.at }, { count: 1, known: true, reason: null, at: "string" });
|
|
assert.deepEqual(list(), { [D]: { count: 1, known: true, reason: null }, [S]: { count: 0, known: true, reason: null } });
|
|
});
|