mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Merge upstream startup accounting proof and fixture isolation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
e2664b655b
3 files changed
+75
-14
No files matched your search
@@ -176,3 +176,32 @@ describe("incremental protocol accounting", () => {
|
||||
expect(second.usage).toMatchObject(Object.fromEntries(Object.entries(fixture.tokens!).map(([key, count]) => [key, count * multiplier])));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("process adapter bootstrap accounting proof", () => {
|
||||
const context = (config: Record<string, unknown>): AdapterExecutionContext => ({
|
||||
runId: `bootstrap-${crypto.randomUUID()}`,
|
||||
agent: { id: "agent", companyId: "company", name: "Process", adapterType: "process", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config, context: {}, onLog: async () => {},
|
||||
});
|
||||
it.each([{}, { command: "/nonexistent-paperclip-accounting-command" },
|
||||
{ command: process.execPath, cwd: "/nonexistent-paperclip-accounting-directory" }])(
|
||||
"proves no provider work for a failure before spawn: %j", async (config) => {
|
||||
const { execute } = await import("../adapters/process/execute.js");
|
||||
const onSpawn = vi.fn();
|
||||
const result = await execute({ ...context(config), onSpawn });
|
||||
expect(result).toMatchObject({ exitCode: 1, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } });
|
||||
expect(result.errorMessage).toBeTruthy();
|
||||
expect(onSpawn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("does not invent bootstrap proof when a spawned process fails", async () => {
|
||||
const { execute } = await import("../adapters/process/execute.js");
|
||||
const onSpawn = vi.fn(async () => { throw new Error("Metadata persistence failed"); });
|
||||
const result = await execute({ ...context({ command: process.execPath, args: ["-e", "process.exit(7)"] }), onSpawn });
|
||||
expect(onSpawn).toHaveBeenCalledOnce();
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.executionRecovery).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
releaseRuntimeServicesForRun,
|
||||
UnresolvedWorkspaceBaseRefError,
|
||||
resetRuntimeServicesForTests,
|
||||
resetRuntimeServicePortReservationsForTests,
|
||||
MANAGED_RUNTIME_PUBLIC_URL_ENV,
|
||||
resolveManagedPaperclipRuntimePublicOrigin,
|
||||
resolveRuntimeProvisionCommand,
|
||||
@@ -454,6 +455,9 @@ afterEach(async () => {
|
||||
delete process.env.PAPERCLIP_WORKTREES_DIR;
|
||||
delete process.env.DATABASE_URL;
|
||||
await resetRuntimeServicesForTests();
|
||||
// Registry reset does not clear the process-local allocation claims. A
|
||||
// failed-start fixture must not reserve another test's ephemeral port.
|
||||
resetRuntimeServicePortReservationsForTests();
|
||||
});
|
||||
|
||||
describe("sanitizeRuntimeServiceBaseEnv", () => {
|
||||
@@ -4099,6 +4103,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
"utf8",
|
||||
);
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = worktreesDir;
|
||||
const canonicalInstanceRoot = await fs.realpath(instanceRoot);
|
||||
|
||||
await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
@@ -4133,7 +4138,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(operations[0]?.command).toBe("printf 'cleanup ok\\n'");
|
||||
expect(operations[1]?.metadata).toMatchObject({
|
||||
cleanupAction: "remove_worktree_instance",
|
||||
instanceRoot,
|
||||
instanceRoot: canonicalInstanceRoot,
|
||||
});
|
||||
expect(operations[2]?.metadata).toMatchObject({
|
||||
cleanupAction: "worktree_remove",
|
||||
@@ -7643,16 +7648,25 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = `runtime-desired-reconcile-${randomUUID()}`;
|
||||
|
||||
const reservePort = async () => {
|
||||
const probe = net.createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
||||
const address = probe.address();
|
||||
const port = typeof address === "object" && address ? address.port : null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
probe.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
if (!port) throw new Error("Failed to reserve runtime reconciliation test port");
|
||||
return port;
|
||||
const reservePorts = async () => {
|
||||
const probes = [net.createServer(), net.createServer()];
|
||||
try {
|
||||
// Keep both sockets open until both allocations finish. Closing the
|
||||
// first probe early lets the kernel return that same port again.
|
||||
for (const probe of probes) await new Promise<void>((resolve, reject) => {
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
return probes.map(probe => {
|
||||
const address = probe.address();
|
||||
if (!address || typeof address !== "object") throw new Error("Failed to reserve runtime reconciliation test port");
|
||||
return address.port;
|
||||
});
|
||||
} finally {
|
||||
await Promise.all(probes.filter(probe => probe.listening).map(probe => new Promise<void>((resolve, reject) => {
|
||||
probe.close(error => error ? reject(error) : resolve());
|
||||
})));
|
||||
}
|
||||
};
|
||||
const isLoopbackPortFree = async (port: number) => {
|
||||
const probe = net.createServer();
|
||||
@@ -7673,8 +7687,8 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
}
|
||||
throw new Error(`Port ${port} did not become free in time`);
|
||||
};
|
||||
const stoppedPort = await reservePort();
|
||||
const livePort = await reservePort();
|
||||
const [stoppedPort, livePort] = await reservePorts();
|
||||
expect(stoppedPort).not.toBe(livePort);
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
@@ -9728,7 +9742,7 @@ describe("realizeExecutionWorkspace with an exact existing branch", () => {
|
||||
|
||||
const workspace = await realizeExistingBranch(repoRoot, "feature/legacy-checkout");
|
||||
|
||||
expect(workspace.cwd).toBe(path.resolve(legacyPath));
|
||||
expect(workspace.cwd).toBe(await fs.realpath(legacyPath));
|
||||
expect(workspace.branchName).toBe("feature/legacy-checkout");
|
||||
expect(workspace.created).toBe(false);
|
||||
expect(await readGit(workspace.cwd, ["rev-parse", "HEAD"])).toBe(branchTip);
|
||||
|
||||
@@ -15,6 +15,24 @@ import {
|
||||
} from "../utils.js";
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
let spawned = false;
|
||||
try {
|
||||
return await executeProcess({ ...ctx, onSpawn: async (meta) => {
|
||||
// Set this before metadata persistence: a failed callback cannot prove
|
||||
// that the already-created process did no billable work.
|
||||
spawned = true;
|
||||
await ctx.onSpawn?.(meta);
|
||||
} });
|
||||
} catch (error) {
|
||||
if (spawned) throw error;
|
||||
return { exitCode: 1, signal: null, timedOut: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function executeProcess(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, config, onLog, onMeta, authToken } = ctx;
|
||||
const command = asString(config.command, "");
|
||||
if (!command) throw new Error("Process adapter missing command");
|
||||
|
||||
Reference in new issue
Block a user