mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
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>
225 lines
15 KiB
TypeScript
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();
|
|
});
|