mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The native Runner path runs an agent turn through a runnerd process that the control plane owns > - A restart of the control plane must keep the task going without a click, if that is safe > - A graceful shutdown closes the HTTP listener first and then drains the heartbeat runs > - A native run that was between provider dispatch and runnerd backend setup in that gap failed with `local_runtime_api_listener_unavailable`, so the drain did not see it as running and did not suspend it for the next boot > - This pull request makes that failure a restart detach: the run stays running with its claim, the drain suspends it, and the next controller adopts it > - The benefit is that a SIGTERM in this window no longer spends a failure and a same-run retry, and the task continues by itself ## Linked Issues or Issue Description Refs #19530 (the same restart window, before provider dispatch). **What happened?** In a restart chaos replay (ACPX Claude, local runner), a restart-recovered run got SIGTERM while `createRunnerdBackendWithinSessionClaim` built the runnerd backend. `resolveLocalRuntimeApiUrl()` threw `local_runtime_api_listener_unavailable` because shutdown had already closed the listener. The run settled `failed`. The drain then logged `restartSuspendedRunIds: []`. The next boot used the one bootstrap retry. A second SIGTERM hit the same window, and the task stopped at a manual Retry. **Expected behavior** A run that this process cannot continue because it is shutting down is handed to the next controller. It is not settled as failed. **Steps to reproduce** 1. Start a native Runner task and restart the server (K1) so that startup recovery resumes the run. 2. Send SIGTERM while the resumed run sets up its runnerd backend. 3. See the run fail with `local_runtime_api_listener_unavailable` and the drain report no suspended runs. ## What Changed - `server/src/runtime-api.ts`: `resolveLocalRuntimeApiUrl()` throws the new `LocalRuntimeApiListenerClosedError` when this process had a listener and closed it (the `listening` flag is false or the `close` event fired). The error message stays `local_runtime_api_listener_unavailable`, so the existing classifiers do not change. A listener that never started still throws the plain error. - `server/src/services/native-runtime/native-session-executor.ts`: for a local Runner, the startup failure handler treats `LocalRuntimeApiListenerClosedError` like a restart detach. It stops lease renewal, keeps the claim and throws `NativeControllerDetachedForRestartError`. Heartbeat already keeps such a run `running`. A remote Runner keeps the same-run bootstrap retry from #19462, because startup recovery has no process evidence to adopt a claimed remote startup (`native-runner-restart-preparation.integration.test.ts` still covers that path). - `doc/execution-semantics.md`: describe the new rule. - Tests: a heartbeat regression that closes the real runtime API listener before backend setup, then drains and restarts; a unit test for the closed versus never-started listener; two `vi.mock` factories keep the real module exports. ## Verification - Red before the fix: `pnpm exec vitest run src/__tests__/heartbeat-native-restart-suspension.test.ts -t listener` failed with `restartSuspendedRunIds: []` and the run `failed` (`local_runtime_api_listener_unavailable`, `bootstrap_retry`). - Green after the fix: the same test passes. The drain suspends the run, the run stays `running` with `awaiting_runner_reattach`, and the next controller claims it and reaches the backend once. - `pnpm exec vitest run src/__tests__/heartbeat-native-restart-suspension.test.ts src/http/server.test.ts src/__tests__/server-startup-feedback-export.test.ts src/services/native-runtime/native-session-executor.test.ts src/services/native-runtime/native-recovered-owner-followup.test.ts`: 1333 tests pass. One timing test in `native-session-executor.test.ts` failed once under host load average ~30 and passed alone. - `pnpm exec tsc --noEmit -p server/tsconfig.json`: pass. - Live, restart chaos harness, local ACPX Claude (`claude-sonnet-5`), `--rc06-masked`, this branch ata35ec8b845(before the remote-scope commit5dc63f24fb, which does not change the local path). The original replay schedule no longer reached the window, because the task now ends before the second fault. So I moved the faults earlier: K1 at 10 s, then K2 as soon as the recovered server is ready. - K1 + K2, 2 runs: both runs PASS with **R1 pass**. Each drain logged `restartSuspendedRunIds: [<run>]` (before: `[]`). The next boot classified the run `resume_dead_runner` and the same run completed. No `local_runtime_api_listener_unavailable` failure. - K1 + K2 + K2, 2 runs: each drain suspended the run. The fourth boot blocked the run with `native_restart_recovery_blocked`, because three restart claims used the three-attempt recovery budget. Retry then completed the task (R3 pass). That budget count is a separate open item. This pull request does not change it. ## Risks - Low. The new path applies only after this process closed its own listener, which happens only during shutdown. The run then waits for the drain or the next boot, as for the other restart detaches. - If shutdown does not reach the drain (for example SIGKILL after the listener closes), the run keeps its live claim until the claim expires. Then startup recovery handles it as a hard restart. This is the same as any run that is running at a hard kill. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (shell, file edits, GitHub CLI). Extended reasoning on. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
13 KiB
TypeScript
253 lines
13 KiB
TypeScript
import { once } from "node:events";
|
|
import dns from "node:dns";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { networkInterfaces, tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { request } from "node:http";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import express from "express";
|
|
import { DurablePrpControlPlane } from "../vendor/paperclip-runner/index.js";
|
|
import { registerRunnerPrpAuthority, runnerPrpWebSocketInternals } from "../realtime/runner-prp-ws.js";
|
|
import { LocalRuntimeApiListenerClosedError, resolveLocalRuntimeApiUrl } from "../runtime-api.js";
|
|
import { createPaperclipHttpServer } from "./server.js";
|
|
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
|
|
|
async function guardedNativeRead(bindHost: string, configuredHost = bindHost) {
|
|
const app = express();
|
|
app.use(privateHostnameGuard({ enabled: true, bindHost: configuredHost, allowedHostnames: [] }));
|
|
app.get("/api/native-read", (req, res) => {
|
|
res.status(req.header("authorization") === "Bearer synthetic-readonly-run-token" ? 200 : 401).json({ readonly: true });
|
|
});
|
|
const server = createPaperclipHttpServer(app, { apiUrl: "https://advertised.example.test" });
|
|
try {
|
|
server.listen(0, bindHost);
|
|
await once(server, "listening");
|
|
const url = `${resolveLocalRuntimeApiUrl()}/api/native-read`;
|
|
const authenticated = await fetch(url, { headers: { Authorization: "Bearer synthetic-readonly-run-token" } });
|
|
expect(authenticated.status).toBe(200);
|
|
expect(await authenticated.json()).toEqual({ readonly: true });
|
|
const unauthenticated = await fetch(url);
|
|
expect(unauthenticated.status).toBe(401);
|
|
await unauthenticated.body?.cancel();
|
|
} finally {
|
|
server.closeAllConnections();
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
}
|
|
|
|
describe("Paperclip HTTP server runner transport", () => {
|
|
afterEach(() => {
|
|
runnerPrpWebSocketInternals.resetForTests();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("registers native runner authority without a separate startup call", async () => {
|
|
const server = createPaperclipHttpServer((_req, res) => res.end("ok"), {
|
|
apiUrl: "http://127.0.0.1:3210",
|
|
});
|
|
const handleUpgrade = vi.fn();
|
|
const runId = "00000000-0000-4000-8000-000000000777";
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const origin = resolveLocalRuntimeApiUrl();
|
|
const authority = await registerRunnerPrpAuthority({
|
|
companyId: "company-1", runId,
|
|
authority: { handleUpgrade } as unknown as DurablePrpControlPlane,
|
|
});
|
|
expect(authority.connectUrl).toBe(`${origin.replace("http:", "ws:")}/api/runner/v1/connect/${runId}`);
|
|
await authority.release();
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
});
|
|
|
|
it("serves HTTP and rejects an unregistered runner over the real listener", async () => {
|
|
const server = createPaperclipHttpServer((_req, res) => res.end("ready"), {
|
|
apiUrl: "http://127.0.0.1:3210",
|
|
});
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Listener not bound");
|
|
const origin = `http://127.0.0.1:${address.port}`;
|
|
try {
|
|
expect(await (await fetch(origin)).text()).toBe("ready");
|
|
const status = await new Promise<number | undefined>((resolve, reject) => {
|
|
const req = request(`${origin}/api/runner/v1/connect/00000000-0000-4000-8000-000000000778`, {
|
|
headers: { connection: "Upgrade", upgrade: "websocket" },
|
|
}, res => { res.resume(); resolve(res.statusCode); });
|
|
req.on("error", reject);
|
|
req.setTimeout(1000, () => req.destroy(new Error("Runner upgrade was not handled")));
|
|
req.end();
|
|
});
|
|
expect(status).toBe(404);
|
|
} finally {
|
|
server.closeAllConnections();
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("refuses native endpoints before listen and after close despite advertised fallback URLs", async () => {
|
|
vi.stubEnv("PAPERCLIP_API_URL", "https://advertised.example.test");
|
|
vi.stubEnv("PAPERCLIP_LISTEN_HOST", "controller.example.test");
|
|
vi.stubEnv("PAPERCLIP_LISTEN_PORT", "3210");
|
|
const server = createPaperclipHttpServer((_req, res) => res.end("ready"), {
|
|
apiUrl: "https://advertised.example.test",
|
|
});
|
|
const binding = { companyId: "company-1", runId: "00000000-0000-4000-8000-000000000779",
|
|
authority: {} as DurablePrpControlPlane };
|
|
expect(() => resolveLocalRuntimeApiUrl()).toThrow();
|
|
await expect(registerRunnerPrpAuthority(binding)).rejects.toThrow();
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
try {
|
|
expect(resolveLocalRuntimeApiUrl()).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
} finally {
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
expect(() => resolveLocalRuntimeApiUrl()).toThrow();
|
|
await expect(registerRunnerPrpAuthority(binding)).rejects.toThrow();
|
|
expect(process.env.PAPERCLIP_API_URL).toBe("https://advertised.example.test");
|
|
});
|
|
|
|
it("uses the actual numeric bind for bearer REST and PRP without a second hostname lookup", async () => {
|
|
const hostname = "controller-bind.example.test";
|
|
let lookups = 0;
|
|
vi.spyOn(dns, "lookup").mockImplementation(((host, options, callback) => {
|
|
if (host !== hostname || typeof options !== "object" || options.all !== true) {
|
|
throw new Error("Expected only the listener's all-address lookup");
|
|
}
|
|
// node:net's listen path requests all addresses; dns.lookup's overloaded
|
|
// declaration otherwise contextualizes this callback as its IPv4 form.
|
|
const lookedUp = callback as (error: NodeJS.ErrnoException | null, addresses?: dns.LookupAddress[]) => void;
|
|
lookups++;
|
|
if (lookups > 1) return lookedUp(Object.assign(new Error("Unexpected second DNS authority"), { code: "ENOTFOUND" }));
|
|
lookedUp(null, [{ address: "127.0.0.1", family: 4 }]);
|
|
}) as typeof dns.lookup);
|
|
vi.stubEnv("PAPERCLIP_API_URL", "https://advertised.example.test");
|
|
vi.stubEnv("PAPERCLIP_LISTEN_HOST", hostname);
|
|
vi.stubEnv("PAPERCLIP_LISTEN_PORT", "1");
|
|
const server = createPaperclipHttpServer((req, res) => {
|
|
res.statusCode = req.headers.authorization === "Bearer synthetic-run-token" ? 200 : 401;
|
|
res.end("local-controller");
|
|
}, { apiUrl: "https://advertised.example.test" });
|
|
const directory = await mkdtemp(join(tmpdir(), "paperclip-bound-prp-"));
|
|
const runId = "00000000-0000-4000-8000-000000000780";
|
|
const authority = new DurablePrpControlPlane({ stateDirectory: directory,
|
|
identity: { runnerInstanceId: "bound-runner", environmentLeaseId: "bound-lease", normalizedSessionId: "bound-session", runId, turnId: "bound-turn", itemId: "bound-item" },
|
|
expectedRunnerVersion: "0.3.0", expectedRunnerDigest: `sha256:${"a".repeat(64)}` });
|
|
let registration: Awaited<ReturnType<typeof registerRunnerPrpAuthority>> | undefined;
|
|
try {
|
|
server.listen(0, hostname);
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Expected a TCP bind");
|
|
const expected = `http://127.0.0.1:${address.port}`;
|
|
expect(resolveLocalRuntimeApiUrl()).toBe(expected);
|
|
expect(await (await fetch(resolveLocalRuntimeApiUrl(), { headers: { Authorization: "Bearer synthetic-run-token" } })).text()).toBe("local-controller");
|
|
registration = await registerRunnerPrpAuthority({ companyId: "company-1", runId, authority });
|
|
expect(registration.connectUrl).toBe(`${expected.replace("http:", "ws:")}/api/runner/v1/connect/${runId}`);
|
|
const upgraded = await new Promise<number | undefined>((resolve, reject) => {
|
|
const req = request(registration!.connectUrl.replace("ws:", "http:"), { headers: {
|
|
Connection: "Upgrade", Upgrade: "websocket", "Sec-WebSocket-Version": "13", "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
} });
|
|
req.once("upgrade", (res, socket) => { socket.destroy(); resolve(res.statusCode); });
|
|
req.once("response", res => { res.resume(); resolve(res.statusCode); });
|
|
req.once("error", reject);
|
|
req.setTimeout(1000, () => req.destroy(new Error("PRP upgrade unavailable")));
|
|
req.end();
|
|
});
|
|
expect(upgraded).toBe(101);
|
|
expect(lookups).toBe(1);
|
|
expect(process.env.PAPERCLIP_API_URL).toBe("https://advertised.example.test");
|
|
} finally {
|
|
await registration?.release();
|
|
await authority.stop();
|
|
server.closeAllConnections();
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["0.0.0.0", "127.0.0.1"], ["::", "[::1]"], ["::1", "[::1]"],
|
|
])("uses matching loopback for a real %s bind", async (host, connectHost) => {
|
|
const server = createPaperclipHttpServer((_req, res) => res.end("bound"), { apiUrl: "https://advertised.example.test" });
|
|
server.listen(0, host);
|
|
await once(server, "listening");
|
|
try {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Expected TCP bind");
|
|
expect(resolveLocalRuntimeApiUrl()).toBe(`http://${connectHost}:${address.port}`);
|
|
expect(await (await fetch(resolveLocalRuntimeApiUrl())).text()).toBe("bound");
|
|
} finally {
|
|
server.closeAllConnections();
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("fails closed for unavailable or nonnumeric bound addresses and competing listeners", async () => {
|
|
const first = createPaperclipHttpServer((_req, res) => res.end("first"), { apiUrl: "http://ignored.example.test" });
|
|
const second = createPaperclipHttpServer((_req, res) => res.end("second"), { apiUrl: "http://ignored.example.test" });
|
|
first.listen(0, "127.0.0.1");
|
|
await once(first, "listening");
|
|
try {
|
|
const address = vi.spyOn(first, "address");
|
|
for (const unavailable of [null, "/tmp/not-tcp.sock", { address: "changed.example.test", family: "IPv4", port: 3210 }]) {
|
|
address.mockReturnValueOnce(unavailable);
|
|
expect(() => resolveLocalRuntimeApiUrl()).toThrow();
|
|
}
|
|
second.listen(0, "127.0.0.1");
|
|
await once(second, "listening");
|
|
expect(() => resolveLocalRuntimeApiUrl()).toThrow("local_runtime_api_listener_ambiguous");
|
|
await expect(registerRunnerPrpAuthority({ companyId: "company-1", runId: "00000000-0000-4000-8000-000000000781", authority: {} as DurablePrpControlPlane }))
|
|
.rejects.toThrow("local_runtime_api_listener_ambiguous");
|
|
} finally {
|
|
await Promise.all([first, second].map(server => new Promise<void>(resolve => server.close(() => resolve()))));
|
|
}
|
|
});
|
|
|
|
it("distinguishes a listener closed for shutdown from one that never served", async () => {
|
|
expect(() => resolveLocalRuntimeApiUrl()).toThrow("local_runtime_api_listener_unavailable");
|
|
expect(() => resolveLocalRuntimeApiUrl()).not.toThrow(LocalRuntimeApiListenerClosedError);
|
|
const server = createPaperclipHttpServer((_req, res) => res.end("closing"), { apiUrl: "http://ignored.example.test" });
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const client = await fetch(resolveLocalRuntimeApiUrl(), { keepalive: true });
|
|
// `close()` stops admission at once; its close event waits for open requests.
|
|
const closed = new Promise<void>(resolve => server.close(() => resolve()));
|
|
expect(() => resolveLocalRuntimeApiUrl()).toThrow(LocalRuntimeApiListenerClosedError);
|
|
await client.text();
|
|
server.closeAllConnections();
|
|
await closed;
|
|
expect(() => resolveLocalRuntimeApiUrl()).toThrow(LocalRuntimeApiListenerClosedError);
|
|
expect(() => resolveLocalRuntimeApiUrl()).toThrow("local_runtime_api_listener_unavailable");
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
try {
|
|
expect(resolveLocalRuntimeApiUrl()).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
} finally {
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it.each(["127.0.0.1", "::1", "::ffff:127.0.0.1"])("routes a guarded native read through the real %s listener", async host => {
|
|
await guardedNativeRead(host);
|
|
});
|
|
|
|
it("routes a guarded native read after a configured hostname binds a local interface", async context => {
|
|
const address = Object.values(networkInterfaces()).flatMap(entries => entries ?? [])
|
|
.find(entry => !entry.internal && entry.family === "IPv4" && !entry.address.startsWith("127."))?.address;
|
|
if (!address) { context.skip(); return; }
|
|
const hostname = "guarded-controller.example.test";
|
|
let lookups = 0;
|
|
vi.spyOn(dns, "lookup").mockImplementation(((host, options, callback) => {
|
|
if (host !== hostname || typeof options !== "object" || options.all !== true) throw new Error("Unexpected lookup in local fixture");
|
|
const lookedUp = callback as (error: NodeJS.ErrnoException | null, addresses?: dns.LookupAddress[]) => void;
|
|
if (++lookups !== 1) throw new Error("Repeated hostname lookup in local fixture");
|
|
lookedUp(null, [{ address, family: 4 }]);
|
|
}) as typeof dns.lookup);
|
|
await guardedNativeRead(hostname);
|
|
expect(lookups).toBe(1);
|
|
});
|
|
});
|