Files
PaperClipAI/packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.test.ts
T
14f961b698 fix(runner): report Runner disconnect age when a Dot bridge exits (#16296)
DurablePrpControlPlane records when the last authenticated Runner connection closes and exposes runnerDisconnectedForMs(). RunnerdDotDriver appends disconnectedMs=<n|none> to dot_runner_process_exited_recovery_required errors. Diagnostic only; authority, classification and recovery are unchanged.

Co-authored-by: Devin Foley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:26:32 -07:00

225 lines
15 KiB
TypeScript

import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expect, it, vi } from "vitest";
import { RunnerdDotDriver, type RunnerdDotDriverOptions } from "./runnerd-dot-driver.js";
import { describeRunnerdNativeSessionBackend } from "../../backends/codex-native-backend.js";
import { createNativeSessionBackend } from "../../backends/native-backend-factory.js";
import * as controlPlane from "../../control-plane/durable-prp-control-plane.js";
import { externalOperationDigest, type ExternalProviderOperation } from "../../contracts/external-provider.js";
import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInputV6 } from "../../contracts/native-execution.js";
import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION,
nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest } from "../../contracts/runtime-context.js";
function execution(root: string, provider: "openai_dot" | "grok_bot" = "openai_dot"): NativeExecutionInputV6 {
const companyId = randomUUID(), agentId = randomUUID();
const digest = "0".repeat(64);
const context = {
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: root, fileCount: 1, totalBytes: 4 } },
skills: [], mcp: { assignmentSetId: "none", digest, bindingId: null },
};
return parseNativeExecutionInput({
schema: "paperclip.native-execution-input.v6",
binding: { companyId, agentId, runId: randomUUID(), issueId: randomUUID(), executionWorkspaceId: randomUUID() },
task: { identifier: "DOT-1", title: "Synthetic bridge recovery", description: null, prompt: "Read the synthetic counter.", workMode: "standard" },
provider: { kind: provider, model: null, binding: { companyId, agentId, bindingId: randomUUID(), bindingGeneration: 1,
acceptByUnixMs: Date.now() + 600_000, expiresAtUnixMs: Date.now() + 7_200_000 } },
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: randomUUID(), driverKind: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
executionMode: "default", planningContext: null,
completionContract: { id: randomUUID(), sha256: "sha256:" + digest, schemaVersion: "paperclip.completion-contract.v1",
contract: { revision: "1", objective: "Read the counter", criteria: [{ id: "objective", requirement: "Read the counter" }] } },
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) }, interactionResponses: [], credentialBindings: [],
}) as NativeExecutionInputV6;
}
it.each(["openai_dot", "grok_bot"] as const)("v6 closes %s identity, workspace, model and credential fields", provider => {
const input = execution("/synthetic/pinned-instructions", provider);
expect(parseNativeExecutionInput(input)).toEqual(input);
expect(buildNativeModelEnvelope(input).workspace).toBeNull();
expect(JSON.stringify(buildNativeModelEnvelope(input))).not.toContain("/synthetic");
for (const changed of [
{ ...input, workspace: { ...input.workspace, cwd: "/private/project" } },
{ ...input, provider: { ...input.provider, model: "a-model" } },
{ ...input, provider: { ...input.provider, apiKey: "secret" } },
{ ...input, provider: { ...input.provider, binding: { ...input.provider.binding, agentId: randomUUID() } } },
{ ...input, credentialBindings: [{ bindingId: "secret-binding", service: "openai", destination: "api.openai.com", expiresAt: null, displayName: "API" }] },
{ ...input, session: { ...input.session, lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60000 } } },
]) expect(() => parseNativeExecutionInput(changed)).toThrow();
});
it.each(["openai_dot", "grok_bot"] as const)("admits the %s descriptor without a broker or process startup", async provider => {
const input = execution("/synthetic/pinned-instructions", provider);
const spawnSpy = vi.spyOn(controlPlane, "spawnRunner");
try {
const descriptor = await describeRunnerdNativeSessionBackend(input);
const backend = createNativeSessionBackend(input, {
dotRunnerOptions: {
stateDirectory: "/must-not-be-created",
identity: { runnerInstanceId: randomUUID(), environmentLeaseId: randomUUID(), runId: input.binding.runId,
normalizedSessionId: input.session.normalizedSessionId!, turnId: "turn", itemId: "item" },
port: {
dispatch: async () => { throw new Error("Descriptor must not dispatch work"); },
settle: async () => { throw new Error("Descriptor must not settle work"); },
attach: async () => { throw new Error("Descriptor must not attach a broker"); },
},
},
});
expect(descriptor).toEqual(await backend.descriptor());
expect(descriptor).toMatchObject({
kind: "runner", name: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", version: provider === "grok_bot" ? "grok-bot-mcp-v1" : "dot-mcp-v1",
capabilities: { resume: false, steering: false, interruption: false, usage: false, dynamicTools: true, completionBoundary: "paperclip_work_session", providerTermination: "unavailable", providerTranscriptStreaming: false },
runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" },
});
expect(spawnSpy).not.toHaveBeenCalled();
} finally { spawnSpy.mockRestore(); }
});
it.each([
{ kind: "shutdown", remote: false }, { kind: "unexpected exit", remote: false },
{ kind: "shutdown", remote: true }, { kind: "unexpected exit", remote: true },
])("classifies a real Rust $kind before the next command poll (remote=$remote)", async ({ kind, remote }) => {
const root = await mkdtemp(join(tmpdir(), "dot-driver-exit-"));
await writeFile(join(root, "AGENTS.md"), "Use only the synthetic counter.");
const input = execution(root);
let handle: controlPlane.RunnerProcessHandle | undefined;
let exited = false;
const spawn = controlPlane.spawnRunner;
const spawnSpy = vi.spyOn(controlPlane, "spawnRunner").mockImplementation(options => {
const process = spawn(options);
// Managed monitors preserve process identity but have no OS exit status.
handle = remote ? { ...process, completion: process.completion.then(result => ({ ...result, code: null })) } : process;
void handle.completion.then(() => { exited = true; });
return handle;
});
const getCommand = controlPlane.DurablePrpControlPlane.prototype.getCommand;
const commandSpy = vi.spyOn(controlPlane.DurablePrpControlPlane.prototype, "getCommand").mockImplementation(function (id) {
const command = getCommand.call(this, id);
// Hold the SDK poll until the real process has exited. The authenticated
// shutdown receipt and its ACK remain committed in the transport journal.
if (id === "dot_shutdown" && command?.status === "completed" && !exited) return { ...command, status: "pending" };
return command;
});
const driver = new RunnerdDotDriver({
execution: input, stateDirectory: join(root, "state"),
identity: { runnerInstanceId: randomUUID(), environmentLeaseId: randomUUID(), runId: input.binding.runId,
normalizedSessionId: input.session.normalizedSessionId!, turnId: "turn-" + input.binding.runId, itemId: "item-" + input.binding.runId },
runnerBinary: resolve("runner/target/debug/paperclip-runnerd"),
port: { dispatch: async () => {}, settle: async () => {}, attach: async () => async () => {} },
});
let session: Awaited<ReturnType<typeof driver.openSession>> | undefined;
try {
session = await driver.openSession({ runId: input.binding.runId, normalizedSessionId: input.session.normalizedSessionId! });
if (kind === "shutdown") {
await expect(session.close({ reason: "Test complete" })).resolves.toBeUndefined();
expect(exited).toBe(true);
expect((await handle!.completion).code).toBe(remote ? null : 0);
} else {
handle!.child.kill("SIGTERM");
await handle!.completion;
await expect(session.read!()).rejects.toThrow(
/^dot_runner_process_exited_recovery_required: code=\S+ signal=\S+ disconnectedMs=(?:none|\d+)$/);
}
} finally {
commandSpy.mockRestore(); spawnSpy.mockRestore();
await session?.detachControllerForRestart?.();
if (!exited) handle?.child.kill("SIGTERM");
await handle?.completion;
await rm(root, { recursive: true, force: true });
}
}, 45000);
it.each(["local", "custom local directory", "external launcher", "grok external launcher"])("reattaches the %s Rust bridge without duplicate operations and refuses lost checkpoints", async launch => {
const root = await mkdtemp(join(tmpdir(), "dot-driver-recovery-"));
await writeFile(join(root, "AGENTS.md"), "Use only the synthetic counter.");
const input = execution(root, launch === "grok external launcher" ? "grok_bot" : "openai_dot");
let send: ((op: ExternalProviderOperation) => Promise<void>) | undefined;
let spawned: { pid: number; processGroupId: number | null; startedAt: string } | undefined;
let calls = 0;
const settlements = new Map<string, unknown>();
const dispatches: unknown[] = [];
let controllerPort = 0;
const options: RunnerdDotDriverOptions = {
execution: input, stateDirectory: join(root, "state"),
identity: { runnerInstanceId: randomUUID(), environmentLeaseId: randomUUID(), runId: input.binding.runId,
normalizedSessionId: input.session.normalizedSessionId!, turnId: "turn-" + input.binding.runId, itemId: "item-" + input.binding.runId },
runnerBinary: resolve("runner/target/debug/paperclip-runnerd"),
onSpawn: process => { spawned = process; },
controlPlaneRegistration: async authority => {
await authority.start(controllerPort);
controllerPort = Number(new URL(authority.connectUrl).port);
return { connectUrl: authority.connectUrl, release: () => authority.stop() };
},
dynamicTools: [{ name: "synthetic_counter", description: "Read a synthetic counter", inputSchema: { type: "object", properties: {}, additionalProperties: false } }],
dynamicToolHandler: async () => ({ count: ++calls }),
port: { dispatch: async event => { dispatches.push(event); }, settle: async event => { settlements.set(String(event.payload.requestId), event.payload.outcome); },
attach: async callback => { send = callback; return async () => { send = undefined; }; } },
};
const runnerState = launch === "local" ? join(root, "state/runner") : join(root, "external-runtime/runner");
if (launch === "custom local directory") options.runnerStateDirectory = runnerState;
if (["external launcher", "grok external launcher"].includes(launch)) {
await mkdir(runnerState, { recursive: true });
options.runnerStateDirectory = runnerState;
options.readProviderState = async () => {
try { return JSON.parse(await readFile(join(runnerState, "dot-provider-state.json"), "utf8")); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
};
options.runnerProcessLauncher = spec => {
expect(spec.environment).not.toHaveProperty("OPENAI_API_KEY");
expect(spec.args).toContain(runnerState);
const child = spawn(spec.command, [...spec.args], { cwd: runnerState, env: spec.environment, stdio: "ignore" });
const startedAt = new Date().toISOString();
spawned = { pid: child.pid!, processGroupId: null, startedAt };
return { child, startedAt, completion: new Promise((resolve, reject) => {
child.once("error", reject); child.once("exit", (code, signal) => resolve({ code, signal, stdout: "", stderr: "" }));
}) };
};
}
const driver = new RunnerdDotDriver(options);
let session = await driver.openSession({ runId: input.binding.runId, normalizedSessionId: input.session.normalizedSessionId! });
const operation = (action: ExternalProviderOperation["action"], args: Record<string, unknown>): ExternalProviderOperation => ({
requestId: randomUUID(), bindingId: input.provider.binding.bindingId, bindingGeneration: 1, runId: input.binding.runId,
normalizedSessionId: input.session.normalizedSessionId!, turnId: options.identity.turnId, assignmentRevision: 1,
action, input: args, digest: externalOperationDigest(action, args),
});
try {
await session.startTurn({ message: { role: "user", text: "Read the synthetic counter." } });
await send!(operation("accept", {}));
const call = operation("tool", { name: "synthetic_counter", arguments: {} });
await send!(call);
await vi.waitFor(() => expect(settlements.get(call.requestId)).toMatchObject({ status: "completed", isError: false }), { timeout: 10000 });
if (input.provider.kind === "grok_bot") {
const renewal = operation("renew", { expiresAtUnixMs: Date.now() + 3600000 });
await send!(renewal);
await vi.waitFor(() => expect(settlements.get(renewal.requestId)).toMatchObject({ status: "renewed" }), { timeout: 10000 });
}
const snapshot = await session.snapshot();
expect(snapshot).toMatchObject({ providerSessionId: null, activeTurnId: options.identity.turnId, providerRecoveryPolicy: "same_session_only" });
await session.detachControllerForRestart!();
const recoveredDriver = new RunnerdDotDriver({ ...options, adoptExistingRunner: { ...spawned!, isAlive: () => true } });
const recovered = await recoveredDriver.recoverSession(snapshot, { signal: new AbortController().signal });
expect(recovered.recovered).toBe(true);
session = recovered.session!;
await send!(call);
expect(calls).toBe(1);
expect(dispatches).toHaveLength(1);
await session.interrupt!({ reason: "Synthetic test complete" });
await session.close({ reason: "Synthetic test complete" });
await rm(join(runnerState, "dot-provider-state.json"));
expect(await recoveredDriver.recoverSession(snapshot, { signal: new AbortController().signal })).toMatchObject({ recovered: false });
} finally {
await session.close({ reason: "Test cleanup", force: true }).catch(() => {});
if (spawned) { try { process.kill(spawned.pid, "SIGTERM"); } catch {} }
await rm(root, { recursive: true, force: true });
}
}, 45000);
it("rejects a mismatched Grok driver before any provider state is attached", async () => {
const input = execution("/synthetic/pinned-instructions", "grok_bot");
expect(() => parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "openai_dot_mcp" } })).toThrow();
expect(() => parseNativeExecutionInput({ ...input, provider: { ...input.provider, kind: "unsupported_bot" } })).toThrow();
});