1141 lines
36 KiB
JavaScript
1141 lines
36 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
createAgentRegistry,
|
|
sessionDirectoryPath,
|
|
} from "../src/bridge/agent-registry.js";
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-multi-runtime-"));
|
|
const home = join(root, "home");
|
|
await mkdir(home);
|
|
return { root, home, sessionRoot: join(root, "sessions") };
|
|
}
|
|
|
|
function adapterFactory({ stateFor } = {}) {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
startAdapter(options) {
|
|
const adapter = {
|
|
sent: [],
|
|
stopped: false,
|
|
async send(command) {
|
|
this.sent.push(command);
|
|
if (command.type === "get_state")
|
|
return {
|
|
type: "response",
|
|
success: true,
|
|
data: stateFor?.(options, calls.length) ?? {},
|
|
};
|
|
return { type: "response", command: command.type, success: true };
|
|
},
|
|
respondToExtension() {},
|
|
async stop() {
|
|
this.stopped = true;
|
|
},
|
|
};
|
|
calls.push({ options, adapter });
|
|
return adapter;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function sessionFile(sessionRoot, worktree, name, id = name) {
|
|
const sessionDir = sessionDirectoryPath(sessionRoot, worktree);
|
|
await mkdir(sessionDir, { recursive: true });
|
|
const sessionPath = join(sessionDir, `${name}.jsonl`);
|
|
await writeFile(
|
|
sessionPath,
|
|
`${JSON.stringify({ type: "session", version: 3, id, cwd: worktree, timestamp: new Date().toISOString() })}\n`,
|
|
);
|
|
return sessionPath;
|
|
}
|
|
|
|
async function readJson(filePath) {
|
|
try {
|
|
return JSON.parse(await readFile(filePath, "utf8"));
|
|
} catch (error) {
|
|
throw new Error(`Could not parse test JSON at ${filePath}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
test("runs independent same-worktree sessions and deduplicates a session lease", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
|
|
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({
|
|
sessionFile: options.sessionPath,
|
|
sessionId: options.sessionPath?.endsWith("one.jsonl") ? "one" : "two",
|
|
}),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const homeRuntime = registry.getWorkspace().directories[0].runtimes[0];
|
|
if (!homeRuntime.sessionPath)
|
|
await registry.closeSessionRuntime(homeRuntime.runtimeId);
|
|
const first = await registry.openSessionRuntime(paths.home, firstPath);
|
|
const second = await registry.openSessionRuntime(paths.home, secondPath);
|
|
const duplicate = await registry.openSessionRuntime(paths.home, firstPath);
|
|
|
|
assert.equal(first.runtimeId, duplicate.runtimeId);
|
|
assert.notEqual(first.runtimeId, second.runtimeId);
|
|
assert.equal(registry.getWorkspaceSummary().openCount, 2);
|
|
assert.equal(new Set(registry.listAgents().map((agent) => agent.id)).size, 2);
|
|
await Promise.all([
|
|
registry.route(first.agentId, "prompt", { message: "first" }),
|
|
registry.route(second.agentId, "prompt", { message: "second" }),
|
|
]);
|
|
assert.deepEqual(
|
|
adapters.calls.slice(-2).map(({ adapter }) => adapter.sent.at(-1).message),
|
|
["first", "second"],
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("concurrent new runtime creation never deduplicates empty sessions", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const [first, second] = await Promise.all([
|
|
registry.createSessionRuntime(paths.home),
|
|
registry.createSessionRuntime(paths.home),
|
|
]);
|
|
assert.notEqual(first.runtimeId, second.runtimeId);
|
|
assert.equal(registry.getWorkspaceSummary().openCount, 3);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("closing removes desired-open intent, preserves history, and refuses directory forget while open", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "saved");
|
|
const feature = join(paths.root, "feature");
|
|
await mkdir(feature);
|
|
const featureSession = await sessionFile(
|
|
paths.sessionRoot,
|
|
feature,
|
|
"feature",
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = await registry.openSessionRuntime(feature, featureSession);
|
|
await assert.rejects(registry.forgetDirectory(feature), /runtimes are open/);
|
|
await registry.route(runtime.agentId, "prompt", {
|
|
message: "work before close",
|
|
});
|
|
await registry.closeSessionRuntime(runtime.runtimeId);
|
|
const retainedDirectory = registry
|
|
.getWorkspace()
|
|
.directories.find((directory) => directory.worktreePath === feature);
|
|
assert.equal(retainedDirectory?.openCount, 0);
|
|
assert.deepEqual(retainedDirectory?.runtimes, []);
|
|
const featureAdapter = adapters.calls.find(
|
|
(call) => call.options.cwd === feature,
|
|
).adapter;
|
|
assert.deepEqual(
|
|
featureAdapter.sent.slice(-2).map((command) => command.type),
|
|
["prompt", "abort"],
|
|
);
|
|
await registry.forgetDirectory(feature);
|
|
assert.match(await readFile(featureSession, "utf8"), /"id":"feature"/);
|
|
assert.equal(
|
|
registry
|
|
.getWorkspace()
|
|
.directories.some((directory) => directory.worktreePath === feature),
|
|
false,
|
|
);
|
|
assert.match(await readFile(saved, "utf8"), /"id":"saved"/);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("restores durable runtime IDs with two-at-a-time concurrency and preserves command isolation", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
|
|
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
|
|
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
|
|
const firstAdapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const firstRegistry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: firstAdapters.startAdapter,
|
|
});
|
|
await firstRegistry.start();
|
|
const initial = firstRegistry.getWorkspace().directories[0].runtimes[0];
|
|
if (!initial.sessionPath)
|
|
await firstRegistry.closeSessionRuntime(initial.runtimeId);
|
|
const one = await firstRegistry.openSessionRuntime(paths.home, firstPath);
|
|
const two = await firstRegistry.openSessionRuntime(paths.home, secondPath);
|
|
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
|
|
await firstRegistry.stop();
|
|
|
|
let activeStarts = 0;
|
|
let maxActiveStarts = 0;
|
|
const gates = [];
|
|
const restoredAdapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const startAdapter = (options) => {
|
|
const adapter = restoredAdapters.startAdapter(options);
|
|
const send = adapter.send.bind(adapter);
|
|
adapter.send = async (command) => {
|
|
if (command.type !== "get_state") return send(command);
|
|
activeStarts += 1;
|
|
maxActiveStarts = Math.max(maxActiveStarts, activeStarts);
|
|
await new Promise((resolve) => gates.push(resolve));
|
|
activeStarts -= 1;
|
|
return send(command);
|
|
};
|
|
return adapter;
|
|
};
|
|
const restored = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter,
|
|
});
|
|
const starting = restored.start();
|
|
while (gates.length < 2)
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(gates.length, 2);
|
|
gates.splice(0).forEach((resolve) => resolve());
|
|
while (gates.length < 1)
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(gates.length, 1);
|
|
gates.splice(0).forEach((resolve) => resolve());
|
|
await starting;
|
|
const ids = restored
|
|
.getWorkspace()
|
|
.directories.flatMap((directory) =>
|
|
directory.runtimes.map((runtime) => runtime.runtimeId),
|
|
);
|
|
assert.deepEqual(
|
|
new Set(ids),
|
|
new Set([one.runtimeId, two.runtimeId, three.runtimeId]),
|
|
);
|
|
assert.equal(maxActiveStarts, 2);
|
|
assert.ok(
|
|
restored
|
|
.listAgents()
|
|
.every((agent) => ![one.agentId, two.agentId].includes(agent.id)),
|
|
);
|
|
await restored.stop();
|
|
});
|
|
|
|
test("serializes close behind an in-flight command and aborts the working runtime", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const runtime = await registry.start();
|
|
const adapter = adapters.calls[0].adapter;
|
|
const originalSend = adapter.send.bind(adapter);
|
|
let releasePrompt;
|
|
const promptGate = new Promise((resolve) => {
|
|
releasePrompt = resolve;
|
|
});
|
|
adapter.send = async (command) => {
|
|
if (command.type === "prompt") {
|
|
adapter.sent.push(command);
|
|
await promptGate;
|
|
return { type: "response", command: "prompt", success: true };
|
|
}
|
|
return originalSend(command);
|
|
};
|
|
const prompt = registry.route(runtime.id, "prompt", { message: "long work" });
|
|
const closing = registry.closeSessionRuntime(runtime.runtimeId);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(adapter.stopped, false);
|
|
releasePrompt();
|
|
await Promise.all([prompt, closing]);
|
|
assert.equal(adapter.stopped, true);
|
|
assert.deepEqual(
|
|
adapter.sent.slice(-2).map((command) => command.type),
|
|
["prompt", "abort"],
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("forces a child kill and retains its lease until exit is confirmed", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"forced-close",
|
|
);
|
|
let killed;
|
|
let releaseStop;
|
|
let blockedSavedAdapter = false;
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
closeTimeoutMs: 5,
|
|
startAdapter: (options) => {
|
|
const blockStop = options.sessionPath === saved && !blockedSavedAdapter;
|
|
if (blockStop) blockedSavedAdapter = true;
|
|
return {
|
|
child: { kill: (signal) => (killed = signal) },
|
|
send: async (command) => ({
|
|
type: "response",
|
|
command: command.type,
|
|
success: true,
|
|
data: { sessionFile: options.sessionPath },
|
|
}),
|
|
respondToExtension() {},
|
|
stop: blockStop
|
|
? () =>
|
|
new Promise((resolve) => {
|
|
releaseStop = resolve;
|
|
})
|
|
: async () => {},
|
|
};
|
|
},
|
|
});
|
|
await registry.start();
|
|
const runtime = await registry.openSessionRuntime(paths.home, saved);
|
|
await registry.closeSessionRuntime(runtime.runtimeId);
|
|
assert.equal(killed, "SIGKILL");
|
|
await assert.rejects(
|
|
registry.openSessionRuntime(paths.home, saved),
|
|
/session runtime is closing/,
|
|
);
|
|
releaseStop();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
|
assert.notEqual(reopened.runtimeId, runtime.runtimeId);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("forgets a managed directory after its worktree is removed", async () => {
|
|
const paths = await fixture();
|
|
const feature = join(paths.root, "feature");
|
|
await mkdir(feature);
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = await registry.selectWorktree(feature);
|
|
await registry.closeSessionRuntime(runtime.runtimeId);
|
|
await rm(feature, { recursive: true });
|
|
await registry.forgetDirectory(feature);
|
|
assert.equal(
|
|
(await registry.listDirectories()).some(
|
|
(directory) => directory.worktreePath === feature,
|
|
),
|
|
false,
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("reserves a legacy switch target before either child is instructed", async () => {
|
|
const paths = await fixture();
|
|
const target = await sessionFile(paths.sessionRoot, paths.home, "target");
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const first = await registry.start();
|
|
const second = await registry.createSessionRuntime(paths.home);
|
|
let releaseSwitch;
|
|
const switchGate = new Promise((resolve) => {
|
|
releaseSwitch = resolve;
|
|
});
|
|
let switched = false;
|
|
const firstAdapter = adapters.calls[0].adapter;
|
|
const firstSend = firstAdapter.send.bind(firstAdapter);
|
|
firstAdapter.send = async (command) => {
|
|
if (command.type === "switch_session") {
|
|
await switchGate;
|
|
switched = true;
|
|
}
|
|
if (command.type === "get_state" && switched)
|
|
return { type: "response", success: true, data: { sessionFile: target } };
|
|
return firstSend(command);
|
|
};
|
|
const switching = registry.route(first.id, "switch_session", {
|
|
sessionPath: target,
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await assert.rejects(
|
|
registry.route(second.agentId, "switch_session", { sessionPath: target }),
|
|
/session is already open/,
|
|
);
|
|
assert.equal(
|
|
adapters.calls[1].adapter.sent.some(
|
|
(command) => command.type === "switch_session",
|
|
),
|
|
false,
|
|
);
|
|
releaseSwitch();
|
|
await switching;
|
|
await registry.stop();
|
|
});
|
|
|
|
test("legacy new session releases old identity and makes its history reopenable", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"saved",
|
|
"saved-id",
|
|
);
|
|
let currentState = {
|
|
sessionFile: saved,
|
|
sessionId: "saved-id",
|
|
sessionName: "Saved",
|
|
};
|
|
const adapters = adapterFactory({ stateFor: () => currentState });
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const original = await registry.start();
|
|
const adapter = adapters.calls[0].adapter;
|
|
const originalSend = adapter.send.bind(adapter);
|
|
adapter.send = async (command) => {
|
|
if (command.type === "new_session") currentState = {};
|
|
return originalSend(command);
|
|
};
|
|
await registry.route(original.id, "new_session");
|
|
const cleared = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(cleared.sessionPath, undefined);
|
|
assert.equal(cleared.sessionId, undefined);
|
|
assert.equal(cleared.label, "New session");
|
|
currentState = { sessionFile: saved, sessionId: "saved-id" };
|
|
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
|
assert.notEqual(reopened.runtimeId, original.runtimeId);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => {
|
|
const paths = await fixture();
|
|
const createdPath = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"created",
|
|
"created-id",
|
|
);
|
|
let stateCalls = 0;
|
|
const adapters = adapterFactory({
|
|
stateFor: () => {
|
|
stateCalls += 1;
|
|
return stateCalls === 1
|
|
? {}
|
|
: { sessionFile: createdPath, sessionId: "created-id" };
|
|
},
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const agent = await registry.start();
|
|
await registry.route(agent.id, "prompt", { message: "create a session" });
|
|
adapters.calls[0].options.onEvent({
|
|
type: "agent_state",
|
|
data: { state: "idle", event: { type: "agent_settled" } },
|
|
});
|
|
while (!registry.getWorkspace().directories[0].runtimes[0].sessionPath)
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await registry.stop();
|
|
const manifestText = await readFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
"utf8",
|
|
);
|
|
assert.match(manifestText, new RegExp(createdPath.replaceAll("/", "\\/")));
|
|
});
|
|
|
|
test("does not persist an uncreated Pi session file across a restart", async () => {
|
|
const paths = await fixture();
|
|
const futurePath = join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"future.jsonl",
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: () => ({ sessionFile: futurePath, sessionId: "future-id" }),
|
|
});
|
|
const firstRegistry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await firstRegistry.start();
|
|
assert.equal(
|
|
firstRegistry.getWorkspace().directories[0].runtimes[0].sessionPath,
|
|
undefined,
|
|
);
|
|
await firstRegistry.stop();
|
|
|
|
const manifest = await readJson(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
);
|
|
assert.equal(manifest.runtimes[0].sessionPath, undefined);
|
|
|
|
const restoredRegistry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await restoredRegistry.start();
|
|
const restored = restoredRegistry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(restored.state, "idle");
|
|
assert.equal(restored.sessionPath, undefined);
|
|
assert.equal(adapters.calls[1].options.sessionPath, undefined);
|
|
await restoredRegistry.stop();
|
|
});
|
|
|
|
test("extension responses do not clear unrelated error attention", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const runtime = await registry.start();
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "request-1", method: "confirm" } },
|
|
});
|
|
adapters.calls[0].options.onError({
|
|
code: "diagnostic",
|
|
message: "needs attention",
|
|
});
|
|
await registry.route(runtime.id, "extension_response", {
|
|
requestId: "request-1",
|
|
response: { confirmed: true },
|
|
});
|
|
const directory = registry.getWorkspace().directories[0];
|
|
assert.equal(directory.attentionCount, 1);
|
|
assert.equal(directory.errorCount, 1);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("successful recovery clears obsolete child error attention", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "saved");
|
|
await writeFile(
|
|
join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"bridge-agent.json",
|
|
),
|
|
`${JSON.stringify({ sessionPath: saved, worktreePath: paths.home })}\n`,
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
let releaseRecovery;
|
|
const recoveryGate = new Promise((resolve) => {
|
|
releaseRecovery = resolve;
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
sleep: () => recoveryGate,
|
|
});
|
|
const runtime = await registry.start();
|
|
adapters.calls[0].options.onError({ code: "child_exited", message: "gone" });
|
|
assert.equal(registry.getWorkspace().directories[0].recoveringCount, 1);
|
|
releaseRecovery();
|
|
await registry.waitForRecovery(runtime.id);
|
|
const recovered = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(recovered.state, "idle");
|
|
assert.equal(recovered.attention, false);
|
|
assert.equal(recovered.error, undefined);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("non-default runtime snapshots do not replace the legacy session pointer", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "first");
|
|
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "second");
|
|
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
|
const referencePath = join(sessionDir, "bridge-agent.json");
|
|
await writeFile(
|
|
referencePath,
|
|
`${JSON.stringify({ sessionPath: firstPath, worktreePath: paths.home })}\n`,
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const secondary = await registry.openSessionRuntime(paths.home, secondPath);
|
|
await registry.getSessionRuntimeSnapshot(secondary.runtimeId);
|
|
const reference = await readFile(referencePath, "utf8");
|
|
assert.match(reference, new RegExp(firstPath.replaceAll("/", "\\/")));
|
|
assert.doesNotMatch(reference, new RegExp(secondPath.replaceAll("/", "\\/")));
|
|
await registry.stop();
|
|
});
|
|
|
|
test("retains a corrupt workspace manifest and reports its issue", async () => {
|
|
const paths = await fixture();
|
|
await mkdir(paths.sessionRoot, { recursive: true });
|
|
const manifestPath = join(paths.sessionRoot, "bridge-workspace-v2.json");
|
|
await writeFile(manifestPath, "{broken", { mode: 0o600 });
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapterFactory().startAdapter,
|
|
});
|
|
await registry.start();
|
|
assert.equal(
|
|
registry.getWorkspace().issue.code,
|
|
"invalid_workspace_manifest",
|
|
);
|
|
assert.equal(await readFile(manifestPath, "utf8"), "{broken");
|
|
await registry.stop();
|
|
});
|
|
|
|
test("retains a canonical duplicate restore record as failed dormant intent", async () => {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "saved");
|
|
const alias = join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"alias.jsonl",
|
|
);
|
|
await symlink(saved, alias);
|
|
await writeFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
`${JSON.stringify({
|
|
version: 2,
|
|
migrated: true,
|
|
runtimes: [
|
|
{
|
|
runtimeId: "runtime-alias",
|
|
worktreePath: paths.home,
|
|
sessionPath: alias,
|
|
},
|
|
{
|
|
runtimeId: "runtime-one",
|
|
worktreePath: paths.home,
|
|
sessionPath: saved,
|
|
},
|
|
],
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtimes = registry.getWorkspace().directories[0].runtimes;
|
|
assert.deepEqual(
|
|
new Set(runtimes.map((runtime) => runtime.runtimeId)),
|
|
new Set(["runtime-one", "runtime-alias"]),
|
|
);
|
|
assert.equal(
|
|
runtimes.filter((runtime) => runtime.state === "failed").length,
|
|
1,
|
|
JSON.stringify(runtimes),
|
|
);
|
|
assert.equal(
|
|
runtimes.find((runtime) => runtime.runtimeId === "runtime-one").state,
|
|
"idle",
|
|
);
|
|
await registry.stop();
|
|
}
|
|
});
|
|
|
|
test("stops a restore candidate when Pi substitutes a different session", async () => {
|
|
const paths = await fixture();
|
|
const requested = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"requested",
|
|
);
|
|
const substituted = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"substituted",
|
|
);
|
|
await writeFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
`${JSON.stringify({
|
|
version: 2,
|
|
migrated: true,
|
|
runtimes: [
|
|
{
|
|
runtimeId: "runtime-requested",
|
|
worktreePath: paths.home,
|
|
sessionPath: requested,
|
|
},
|
|
],
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: () => ({ sessionFile: substituted }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(runtime.state, "failed");
|
|
assert.equal(runtime.sessionPath, requested);
|
|
assert.equal(adapters.calls[0].adapter.stopped, true);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("shutdown drains a create racing before runtime indexing", async () => {
|
|
const paths = await fixture();
|
|
const feature = join(paths.root, "feature");
|
|
await mkdir(feature);
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const creating = registry.createSessionRuntime(feature);
|
|
const stopping = registry.stop();
|
|
await assert.rejects(creating, /agent registry is stopping/);
|
|
await stopping;
|
|
assert.equal(adapters.calls.length, 1);
|
|
assert.equal(adapters.calls[0].adapter.stopped, true);
|
|
assert.equal(registry.listAgents().length, 1);
|
|
});
|
|
|
|
test("legacy selection prefers the manifest default and promotes a fallback on close", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"first",
|
|
"first-id",
|
|
);
|
|
const secondPath = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"second",
|
|
"second-id",
|
|
);
|
|
await mkdir(paths.sessionRoot, { recursive: true });
|
|
await writeFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
`${JSON.stringify({
|
|
version: 2,
|
|
migrated: true,
|
|
runtimes: [
|
|
{
|
|
runtimeId: "secondary",
|
|
worktreePath: paths.home,
|
|
sessionPath: secondPath,
|
|
},
|
|
{
|
|
runtimeId: "default",
|
|
worktreePath: paths.home,
|
|
sessionPath: firstPath,
|
|
legacyDefault: true,
|
|
},
|
|
],
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({
|
|
sessionFile: options.sessionPath,
|
|
sessionId: options.sessionPath === firstPath ? "first-id" : "second-id",
|
|
}),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const selectedDefault = await registry.selectWorktree(paths.home);
|
|
assert.equal(selectedDefault.runtimeId, "default");
|
|
await registry.closeSessionRuntime("default");
|
|
const selectedFallback = await registry.selectWorktree(paths.home);
|
|
assert.equal(selectedFallback.runtimeId, "secondary");
|
|
const reference = await readJson(
|
|
join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"bridge-agent.json",
|
|
),
|
|
);
|
|
assert.equal(reference.sessionPath, secondPath);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("concurrent closes never promote a closing runtime as legacy default", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "first");
|
|
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "second");
|
|
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "third");
|
|
await mkdir(paths.sessionRoot, { recursive: true });
|
|
await writeFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
`${JSON.stringify({
|
|
version: 2,
|
|
migrated: true,
|
|
runtimes: [
|
|
{
|
|
runtimeId: "default",
|
|
worktreePath: paths.home,
|
|
sessionPath: firstPath,
|
|
legacyDefault: true,
|
|
},
|
|
{
|
|
runtimeId: "closing",
|
|
worktreePath: paths.home,
|
|
sessionPath: secondPath,
|
|
},
|
|
{
|
|
runtimeId: "survivor",
|
|
worktreePath: paths.home,
|
|
sessionPath: thirdPath,
|
|
},
|
|
],
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
await Promise.all([
|
|
registry.closeSessionRuntime("closing"),
|
|
registry.closeSessionRuntime("default"),
|
|
]);
|
|
const selected = await registry.selectWorktree(paths.home);
|
|
assert.equal(selected.runtimeId, "survivor");
|
|
const manifest = await readJson(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
);
|
|
assert.equal(manifest.runtimes.length, 1);
|
|
assert.equal(manifest.runtimes[0].runtimeId, "survivor");
|
|
assert.equal(manifest.runtimes[0].legacyDefault, true);
|
|
const reference = await readJson(
|
|
join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"bridge-agent.json",
|
|
),
|
|
);
|
|
assert.equal(reference.sessionPath, thirdPath);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("closing retains a saved-session lease until the child stops", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "saved");
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = await registry.openSessionRuntime(paths.home, saved);
|
|
const adapter = adapters.calls.find(
|
|
(call) => call.options.sessionPath === saved,
|
|
).adapter;
|
|
let releaseStop;
|
|
adapter.stop = () =>
|
|
new Promise((resolve) => {
|
|
releaseStop = () => {
|
|
adapter.stopped = true;
|
|
resolve();
|
|
};
|
|
});
|
|
const closing = registry.closeSessionRuntime(runtime.runtimeId);
|
|
while (!releaseStop) await new Promise((resolve) => setImmediate(resolve));
|
|
await assert.rejects(
|
|
registry.openSessionRuntime(paths.home, saved),
|
|
/session runtime is closing/,
|
|
);
|
|
releaseStop();
|
|
await closing;
|
|
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
|
assert.notEqual(reopened.runtimeId, runtime.runtimeId);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("first migration retains a missing legacy session as failed dormant intent", async () => {
|
|
const paths = await fixture();
|
|
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
|
const missing = join(sessionDir, "missing.jsonl");
|
|
await mkdir(sessionDir, { recursive: true });
|
|
await writeFile(
|
|
join(sessionDir, "bridge-agent.json"),
|
|
`${JSON.stringify({ sessionPath: missing, worktreePath: paths.home })}\n`,
|
|
);
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(runtime.state, "failed");
|
|
assert.equal(runtime.sessionPath, missing);
|
|
assert.equal(adapters.calls.length, 0);
|
|
const manifest = await readJson(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
);
|
|
assert.equal(manifest.runtimes[0].sessionPath, missing);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("extension responses are scoped to actionable requests on one runtime", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const first = await registry.start();
|
|
const second = await registry.createSessionRuntime(paths.home);
|
|
const replies = [[], []];
|
|
adapters.calls[0].adapter.respondToExtension = (...args) =>
|
|
replies[0].push(args);
|
|
adapters.calls[1].adapter.respondToExtension = (...args) =>
|
|
replies[1].push(args);
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "collision", method: "confirm" } },
|
|
});
|
|
await assert.rejects(
|
|
registry.route(second.agentId, "extension_response", {
|
|
requestId: "collision",
|
|
response: { confirmed: true },
|
|
}),
|
|
/not actionable for this runtime/,
|
|
);
|
|
adapters.calls[1].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "collision", method: "confirm" } },
|
|
});
|
|
await registry.route(second.agentId, "extension_response", {
|
|
requestId: "collision",
|
|
response: { confirmed: false },
|
|
});
|
|
await registry.route(first.id, "extension_response", {
|
|
requestId: "collision",
|
|
response: { confirmed: true },
|
|
});
|
|
assert.equal(replies[0].length, 1);
|
|
assert.equal(replies[1].length, 1);
|
|
await assert.rejects(
|
|
registry.route(first.id, "extension_response", {
|
|
requestId: "collision",
|
|
response: { confirmed: false },
|
|
}),
|
|
/not actionable for this runtime/,
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("manual restart ignores late extension dialogs from the replaced child", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
let releaseStop;
|
|
const stopGate = new Promise((resolve) => {
|
|
releaseStop = resolve;
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter(options) {
|
|
const adapter = adapters.startAdapter(options);
|
|
if (adapters.calls.length === 1) adapter.stop = () => stopGate;
|
|
return adapter;
|
|
},
|
|
sleep: async () => {},
|
|
});
|
|
const runtime = await registry.start();
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "restart-request", method: "confirm" } },
|
|
});
|
|
const restarting = registry.restart(runtime.id);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "late-old-child", method: "confirm" } },
|
|
});
|
|
releaseStop();
|
|
await restarting;
|
|
const snapshot = await registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
|
assert.deepEqual(snapshot.extensions, []);
|
|
assert.equal(snapshot.runtime.attention, false);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("child exit clears extension dialogs that recovery cannot answer", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
let releaseRecovery;
|
|
const recoveryGate = new Promise((resolve) => {
|
|
releaseRecovery = resolve;
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
sleep: () => recoveryGate,
|
|
});
|
|
const runtime = await registry.start();
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "dead-request", method: "input" } },
|
|
});
|
|
adapters.calls[0].options.onError({ code: "child_exited", message: "gone" });
|
|
const duringRecovery = await registry.getSessionRuntimeSnapshot(
|
|
runtime.runtimeId,
|
|
);
|
|
assert.deepEqual(duringRecovery.extensions, []);
|
|
releaseRecovery();
|
|
await registry.waitForRecovery(runtime.id);
|
|
const recovered = await registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
|
assert.deepEqual(recovered.extensions, []);
|
|
assert.equal(recovered.runtime.attention, false);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("snapshot rejects results from an adapter replaced during recovery", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
sleep: async () => {},
|
|
});
|
|
const runtime = await registry.start();
|
|
const oldAdapter = adapters.calls[0].adapter;
|
|
const oldSend = oldAdapter.send.bind(oldAdapter);
|
|
let releaseSnapshot;
|
|
let snapshotStarted;
|
|
const snapshotGate = new Promise((resolve) => {
|
|
snapshotStarted = resolve;
|
|
});
|
|
let stateCalls = 0;
|
|
oldAdapter.send = async (command) => {
|
|
if (command.type === "get_state" && ++stateCalls === 1) {
|
|
snapshotStarted();
|
|
await new Promise((resolve) => {
|
|
releaseSnapshot = resolve;
|
|
});
|
|
}
|
|
return oldSend(command);
|
|
};
|
|
const snapshot = registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
|
await snapshotGate;
|
|
adapters.calls[0].options.onError({ code: "child_exited", message: "gone" });
|
|
while (adapters.calls.length < 2)
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
releaseSnapshot();
|
|
await assert.rejects(snapshot, /session runtime is closing/);
|
|
await registry.waitForRecovery(runtime.id);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("closing the last runtime persists a zero-runtime restart", async () => {
|
|
const paths = await fixture();
|
|
const firstAdapters = adapterFactory();
|
|
const first = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: firstAdapters.startAdapter,
|
|
});
|
|
const runtime = await first.start();
|
|
await first.closeSessionRuntime(runtime.runtimeId);
|
|
await first.stop();
|
|
const restoredAdapters = adapterFactory();
|
|
const restored = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: restoredAdapters.startAdapter,
|
|
});
|
|
await restored.start();
|
|
assert.equal(restored.getWorkspaceSummary().openCount, 0);
|
|
assert.equal(restoredAdapters.calls.length, 0);
|
|
await restored.stop();
|
|
});
|
|
|
|
test("workspace replay is globally ordered and reports truncation boundaries", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
eventLimit: 2,
|
|
});
|
|
await registry.start();
|
|
adapters.calls[0].options.onEvent({ type: "stream", data: { event: {} } });
|
|
adapters.calls[0].options.onEvent({ type: "tool", data: { event: {} } });
|
|
adapters.calls[0].options.onEvent({ type: "queue", data: { event: {} } });
|
|
const replay = registry.workspaceEventsAfter(1);
|
|
assert.equal(replay.truncated, true);
|
|
assert.equal(registry.workspaceEventsAfter(0).truncated, true);
|
|
assert.equal(replay.events.length, 2);
|
|
assert.ok(replay.events[0].seq < replay.events[1].seq);
|
|
assert.equal(replay.bridgeInstanceId, registry.bridgeInstanceId);
|
|
await registry.stop();
|
|
});
|