From b163c6c47326f2b39443b473e2dae854c7e30da8 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:55:59 -0700 Subject: [PATCH] fix(server): preserve hot restart intent across path upgrade (#10593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The server can preserve eligible agent runs during a controlled hot restart. > - A path change moved restart state from the Paperclip home root to the instance root. > - A staged update can therefore make the old server and the new server read different intent files. > - The old server then drains live runs, while the new server can start without a shutdown snapshot. > - This pull request adds a correlated compatibility handoff and records the live preflight set. > - It also verifies the target process instance on Linux, macOS, and Windows. > - The benefit is complete and safe run classification across the path upgrade. ## Linked Issues or Issue Description No public GitHub issue covers this defect. **What happened?** A staged hot restart can run an older server that reads `hot-restart-intent.json` from the Paperclip home root and a new server that writes the file under the instance root. The old server misses the request and uses graceful drain. The new server later finds its marker without a shutdown snapshot. Before this change, that state could produce an empty loss list even when live runs existed before restart. **Expected behavior** The old server must receive the PID-targeted restart request at its legacy path. The new server must correlate the legacy shutdown snapshot with its instance-scoped request. Every run that was live during preflight must appear as adopted, finalized while down, or lost. A reused PID must not let a stale marker claim a different process instance. **Steps to reproduce** 1. Start a server version from before the instance-root marker change. 2. Keep one or more local-agent heartbeat runs active. 3. Stage a current build and request a hot restart from that build. 4. Observe that the old server reads only the home-root path while the staged build writes only the instance-root path. 5. Observe graceful drain and a new-server intent that has no shutdown snapshot. **Paperclip version or commit** The path transition entered `master` in #10045. The hot-restart adoption flow came from #9647. This fix targets current `master` and compatibility with the immediately preceding home-root behavior. **Deployment mode** Self-hosted server built from source with controlled service hot restarts. Related work: #9628 is the original broader hot-restart feature PR. #10556 addresses embedded PostgreSQL lifecycle behavior and does not address marker-path compatibility. ## What Changed - Write an authoritative instance-scoped intent and a correlated legacy home-root handoff marker. - Merge a legacy shutdown snapshot only when immutable request identity fields match. - Prevent a non-default instance from consuming an uncorrelated legacy-only marker. - Record preflight running heartbeat IDs and reconcile snapshot omissions from current database state. - Serialize marker claims, snapshot writes, stale recovery, and matching cleanup with recoverable per-path filesystem leases. - Read process start identity on Linux, macOS, and Windows to distinguish a reused PID from the original server. - Require identity for new restart requests and fail closed when a supported platform cannot provide it. - Classify older markers by comparing the replacement server boot time or operating-system process start time with the request time. - Close the preflight database client explicitly and use a root-safe SQL query. - Add focused unit, platform-branch, database-backed, and CI regression coverage. - Document the compatibility handoff, process identity probes, and instance-scoped report path. ## Verification - `pnpm exec vitest run server/src/services/hot-restart.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts -t "hot-restart|old-server legacy|preflight live|preflight run|spawn identity before hot restart"` — 24 tests passed and 90 tests were skipped across 2 files. - `pnpm exec vitest run server/src/services/hot-restart.test.ts` — 17 tests passed. - `pnpm exec vitest run server/src/__tests__/issue-watchdogs-routes.test.ts -t "restarts a stalled claimed run"` — 1 test passed and 10 tests were skipped. - `pnpm exec vitest run server/src/__tests__/agent-action-audit-routes.test.ts -t "allows an agent with issue:delegate"` — 1 test passed and 7 tests were skipped. - `pnpm --filter @paperclipai/server typecheck` — passed. - `git diff --check` — passed. - GitHub Actions — 26 of 26 checks passed at `55a79cb029be8b1dc89926d9d89ccd2181266d5c`. - Greptile — 5/5 at the same head with no unresolved current-head review threads. ## Risks - The legacy handoff path is shared across instances. Exclusive claims and per-path leases prevent overwrite and match-before-delete races. - Process identity uses platform commands as a fallback when the health endpoint has no identity. Linux reads `/proc`, macOS and BSD use `ps`, and Windows uses PowerShell. - A supported-platform identity probe failure aborts the restart. This fails closed instead of replacing an unknown live process. - Older intent files do not contain process identity. The server compares the replacement boot or process start time with the request time when those values are available. - A preflight database read can fail before the marker is written. The command fails closed instead of claiming a restart whose live-run set is unknown. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5. The exact deployment model ID and context-window size were not exposed by this runtime. Reasoning, repository editing, shell execution, test execution, GitHub CLI, and Paperclip API capabilities were enabled. ## 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 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 --------- Co-authored-by: Paperclip --- doc/DEVELOPING.md | 22 +- scripts/request-hot-restart.ts | 59 ++- .../agent-action-audit-routes.test.ts | 2 +- .../src/__tests__/decisions-service.test.ts | 5 +- .../heartbeat-process-recovery.test.ts | 148 ++++++ .../__tests__/issue-watchdogs-routes.test.ts | 33 +- server/src/services/heartbeat.ts | 39 +- server/src/services/hot-restart.test.ts | 476 ++++++++++++++++++ server/src/services/hot-restart.ts | 415 ++++++++++++++- 9 files changed, 1172 insertions(+), 27 deletions(-) create mode 100644 server/src/services/hot-restart.test.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index cd7b9de2c5..821f864da3 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -115,7 +115,27 @@ pnpm --filter @paperclipai/server exec tsx ../scripts/request-hot-restart.ts --s systemctl restart paperclip.service ``` -Use `--drain-required` only when the deploy intentionally requires the old terminate-and-retry behavior. Without that flag, the old server verifies that the marker targets its own PID, snapshots currently running heartbeat run IDs and child PIDs, and skips the shutdown drain so eligible detached local-agent processes can keep running. On startup the new server writes `$PAPERCLIP_HOME/hot-restart-report.json` with `previousServerPid`, `newServerPid`, `previousServerVersion`, `newServerVersion`, `adoptedRunIds`, `finalizedWhileDownRunIds`, `lostRunIds`, and per-run classifications before the normal orphan reaper runs. +The staged command records the target server's boot identity and operating +system process start time with the PID. It reads process metadata through +`/proc` on Linux, `ps` on macOS and BSD, and PowerShell on Windows. These +identities let a later request reclaim an abandoned marker after the operating +system recycles the numeric PID. Older markers stay compatible and use process +start metadata when available. When OS metadata is unavailable, the current +server's health-reported boot time can still prove that a legacy marker predates +the process now using its PID. Paperclip refuses to create a new request without +at least one identity source. Supported-platform process probes fail explicitly +instead of silently treating a live PID as either the original owner or a +recycled process when identity cannot be established. + +Use `--drain-required` only when the deploy intentionally requires the old terminate-and-retry behavior. Without that flag, the old server verifies that the marker targets its own PID, snapshots currently running heartbeat run IDs and child PIDs, and skips the shutdown drain so eligible detached local-agent processes can keep running. On startup the new server writes `$PAPERCLIP_HOME/instances/${PAPERCLIP_INSTANCE_ID:-default}/hot-restart-report.json` with `previousServerPid`, `newServerPid`, `previousServerVersion`, `newServerVersion`, `adoptedRunIds`, `finalizedWhileDownRunIds`, `lostRunIds`, and per-run classifications before the normal orphan reaper runs. + +The request command records the preflight set of running heartbeat IDs and writes +an instance-scoped marker plus a PID-targeted legacy home-root handoff marker. +This lets a previous server version capture its snapshot at the old path while +the new server correlates that snapshot back to the authoritative instance +request. If any preflight run ID is absent from the shutdown snapshot, the +startup report includes it in `lostRunIds`; a missing snapshot therefore cannot +look like a zero-loss restart. A healthy guarded deploy must compare the report against `/api/health` (`version` or `serverVersion`) and treat any `lostRunIds` entry as a continuity failure that needs recovery before marking deployment complete. diff --git a/scripts/request-hot-restart.ts b/scripts/request-hot-restart.ts index 1f7ca84cd5..af612ef018 100644 --- a/scripts/request-hot-restart.ts +++ b/scripts/request-hot-restart.ts @@ -1,4 +1,6 @@ #!/usr/bin/env -S node --import tsx +import { createDb } from "../packages/db/src/index.js"; +import { loadConfig } from "../server/src/config.js"; import { resolveHotRestartIntentPath, writeHotRestartIntent, @@ -8,7 +10,7 @@ function usage(): never { console.error([ "Usage: tsx scripts/request-hot-restart.ts --server-pid [--drain-required]", "", - "Writes a one-shot hot-restart intent marker under PAPERCLIP_HOME.", + "Writes an instance-scoped hot-restart intent plus a legacy home-root handoff marker.", ].join("\n")); process.exit(2); } @@ -47,37 +49,72 @@ function normalizeApiBase(raw: string | undefined) { return trimmed.replace(/\/+$/, "").replace(/\/api$/, ""); } -async function readPreviousServerVersion() { +async function readPreviousServerInfo() { const apiBase = normalizeApiBase(process.env.PAPERCLIP_API_URL); - if (!apiBase) return null; + if (!apiBase) return { version: null, identity: null }; try { + const apiKey = process.env.PAPERCLIP_API_KEY?.trim(); const response = await fetch(`${apiBase}/api/health`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, signal: AbortSignal.timeout(2_000), }); - if (!response.ok) return null; + if (!response.ok) return { version: null, identity: null }; const body = await response.json() as Record; - return typeof body.serverVersion === "string" - ? body.serverVersion - : typeof body.version === "string" - ? body.version - : null; + const serverInfo = body.serverInfo && typeof body.serverInfo === "object" + ? body.serverInfo as Record + : null; + return { + version: typeof body.serverVersion === "string" + ? body.serverVersion + : typeof body.version === "string" + ? body.version + : null, + identity: typeof serverInfo?.processStartedAt === "string" + ? serverInfo.processStartedAt + : null, + }; } catch { - return null; + return { version: null, identity: null }; + } +} + +async function readPreflightActiveRunIds() { + const config = loadConfig(); + const dbUrl = process.env.DATABASE_URL?.trim() + || config.databaseUrl + || `postgres://paperclip:paperclip@127.0.0.1:${config.embeddedPostgresPort}/paperclip`; + const db = createDb(dbUrl); + try { + const rows = await db.$client<{ id: string }[]>` + SELECT id + FROM heartbeat_runs + WHERE status = 'running' + `; + return rows.map((row) => row.id); + } finally { + await db.$client.end({ timeout: 1 }); } } const { serverPid, drainRequired } = readArgs(process.argv.slice(2)); +const preflightActiveRunIds = drainRequired ? [] : await readPreflightActiveRunIds(); +const previousServerInfo = await readPreviousServerInfo(); const intent = await writeHotRestartIntent({ previousServerPid: serverPid, - previousServerVersion: await readPreviousServerVersion(), + previousServerIdentity: previousServerInfo.identity, + previousServerVersion: previousServerInfo.version, drainRequired, requestedByRunId: process.env.PAPERCLIP_RUN_ID?.trim() || null, + preflightActiveRunIds, }); console.log(JSON.stringify({ status: "hot_restart_intent_written", intentPath: resolveHotRestartIntentPath(), previousServerPid: intent.previousServerPid, + previousServerIdentity: intent.previousServerIdentity, + previousServerStartedAt: intent.previousServerStartedAt, previousServerVersion: intent.previousServerVersion, drainRequired: intent.drainRequired, + preflightActiveRunIds: intent.preflightActiveRunIds, }, null, 2)); diff --git a/server/src/__tests__/agent-action-audit-routes.test.ts b/server/src/__tests__/agent-action-audit-routes.test.ts index 4cbe95e14f..2c068fe055 100644 --- a/server/src/__tests__/agent-action-audit-routes.test.ts +++ b/server/src/__tests__/agent-action-audit-routes.test.ts @@ -127,7 +127,7 @@ describePostgres("agent action audit routes", () => { })).get(`/api/companies/${company.id}/audit/agent-actions`); expect(boardResponse.status).toBe(403); expect(boardResponse.body.error).toContain("audit:view_agent_actions"); - }); + }, 30_000); it("returns a client error for invalid audit query parameters", async () => { const { company } = await seed(); diff --git a/server/src/__tests__/decisions-service.test.ts b/server/src/__tests__/decisions-service.test.ts index d223dc5621..7c0a546dbd 100644 --- a/server/src/__tests__/decisions-service.test.ts +++ b/server/src/__tests__/decisions-service.test.ts @@ -185,8 +185,9 @@ describePg("decisionService", () => { }); it("expires a decision atomically instead of executing after its deadline", async () => { - const created = await createCommentDecision("lenient", { expiresAt: new Date(Date.now() + 5) }); - await new Promise((resolve) => setTimeout(resolve, 10)); + const created = await createCommentDecision("lenient"); + await db.update(decisions).set({ expiresAt: new Date(Date.now() - 1) }) + .where(eq(decisions.id, created.id)); await expect(service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() })) .rejects.toThrow("decision_expired"); expect((await service().get(created.id))?.status).toBe("expired"); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 5428733352..413d2d29d2 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -106,6 +106,7 @@ import { } from "../services/heartbeat.ts"; import { readHotRestartIntent, + resolveLegacyHotRestartIntentPath, resolveHotRestartReportPath, writeHotRestartIntent, } from "../services/hot-restart.ts"; @@ -1489,6 +1490,153 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); }); + it("adopts an old-server legacy snapshot written for a new instance-scoped marker", async () => { + const child = spawnAliveProcess(); + childProcesses.add(child); + expect(child.pid).toBeGreaterThan(0); + const { companyId, agentId, issueId, runId } = await seedRunFixture({ + agentStatus: "running", + processPid: child.pid ?? null, + processGroupId: null, + }); + + await withTempPaperclipHome(async (home) => { + await writeHotRestartIntent({ + previousServerPid: process.pid, + previousServerVersion: "old-home-root-version", + requestedAt: new Date("2026-08-01T00:05:00.000Z"), + requestedByRunId: "deploy-run", + preflightActiveRunIds: [runId], + }); + + // Simulate the previous binary: it reads and rewrites only the legacy + // home-root marker, and its parser drops fields introduced by the new binary. + const legacyPath = resolveLegacyHotRestartIntentPath(home); + const legacyIntent = JSON.parse(await fs.readFile(legacyPath, "utf8")) as Record; + delete legacyIntent.preflightActiveRunIds; + legacyIntent.shutdownSnapshot = { + capturedAt: "2026-08-01T00:06:00.000Z", + signal: "SIGTERM", + activeRuns: [{ + runId, + companyId, + agentId, + adapterType: "codex_local", + status: "running", + processPid: child.pid, + processGroupId: null, + issueId, + }], + }; + await fs.writeFile(legacyPath, `${JSON.stringify(legacyIntent, null, 2)}\n`, "utf8"); + + const mergedIntent = await readHotRestartIntent(); + expect(mergedIntent).toMatchObject({ + preflightActiveRunIds: [runId], + shutdownSnapshot: { + activeRuns: [expect.objectContaining({ runId, processPid: child.pid })], + }, + }); + + const heartbeat = heartbeatService(db); + const adoption = await heartbeat.reconcileHotRestartAdoption( + new Date("2026-08-01T00:07:00.000Z"), + ); + expect(adoption).toMatchObject({ + mode: "reported", + adoptedRunIds: [runId], + finalizedWhileDownRunIds: [], + lostRunIds: [], + }); + }); + }); + + it("reports preflight live runs as lost when the shutdown snapshot is missing", async () => { + const { runId } = await seedRunFixture({ + agentStatus: "running", + processPid: process.pid, + processGroupId: null, + }); + + await withTempPaperclipHome(async (home) => { + await writeHotRestartIntent({ + previousServerPid: process.pid, + previousServerVersion: "missing-snapshot-version", + requestedAt: new Date("2026-08-01T01:05:00.000Z"), + preflightActiveRunIds: [runId], + }); + + const heartbeat = heartbeatService(db); + const adoption = await heartbeat.reconcileHotRestartAdoption( + new Date("2026-08-01T01:07:00.000Z"), + ); + expect(adoption).toMatchObject({ + mode: "reported", + adoptedRunIds: [], + finalizedWhileDownRunIds: [], + lostRunIds: [runId], + }); + + const report = JSON.parse( + await fs.readFile(resolveHotRestartReportPath(home), "utf8"), + ) as Record; + expect(report).toMatchObject({ + adoptedRunIds: [], + finalizedWhileDownRunIds: [], + lostRunIds: [runId], + }); + }); + }); + + it("reports a preflight run that finished before snapshot capture as finalized", async () => { + const { runId } = await seedRunFixture({ + agentStatus: "running", + processPid: process.pid, + processGroupId: null, + }); + + await withTempPaperclipHome(async (home) => { + await writeHotRestartIntent({ + previousServerPid: process.pid, + previousServerVersion: "preflight-race-version", + requestedAt: new Date("2026-08-01T01:08:00.000Z"), + preflightActiveRunIds: [runId], + }); + await db + .update(heartbeatRuns) + .set({ + status: "succeeded", + finishedAt: new Date("2026-08-01T01:08:01.000Z"), + updatedAt: new Date("2026-08-01T01:08:01.000Z"), + }) + .where(eq(heartbeatRuns.id, runId)); + + const heartbeat = heartbeatService(db); + const adoption = await heartbeat.reconcileHotRestartAdoption( + new Date("2026-08-01T01:09:00.000Z"), + ); + expect(adoption).toMatchObject({ + mode: "reported", + adoptedRunIds: [], + finalizedWhileDownRunIds: [runId], + lostRunIds: [], + }); + + const report = JSON.parse( + await fs.readFile(resolveHotRestartReportPath(home), "utf8"), + ) as { runs?: Array> }; + expect(report.runs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + runId, + classification: "finalized_while_down", + reason: "run_status_succeeded", + }), + ]), + ); + }); + }); + it("persists codex_local spawn identity before hot restart and never loses the live run for missing metadata", async () => { let releaseAdapter: (() => void) | null = null; let spawnedPid: number | null = null; diff --git a/server/src/__tests__/issue-watchdogs-routes.test.ts b/server/src/__tests__/issue-watchdogs-routes.test.ts index 3d2922f492..dd5636d179 100644 --- a/server/src/__tests__/issue-watchdogs-routes.test.ts +++ b/server/src/__tests__/issue-watchdogs-routes.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { and, eq } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, agentRuntimeState, @@ -10,6 +10,7 @@ import { agents, companies, companyMemberships, + companySkills, createDb, heartbeatRunEvents, heartbeatRuns, @@ -24,9 +25,35 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { errorHandler } from "../middleware/index.js"; +import { runningProcesses } from "../adapters/index.ts"; import { issueRoutes } from "../routes/issues.js"; +import { heartbeatService } from "../services/heartbeat.js"; import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; import { taskWatchdogService } from "../services/task-watchdogs.js"; +import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; + +const mockAdapterExecute = vi.hoisted(() => + vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Issue watchdog route test run.", + provider: "test", + model: "test-model", + })), +); + +vi.mock("../adapters/index.ts", async () => { + const actual = await vi.importActual("../adapters/index.ts"); + return { + ...actual, + getServerAdapter: vi.fn(() => ({ + supportsLocalAgentJwt: false, + execute: mockAdapterExecute, + })), + }; +}); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -47,6 +74,9 @@ describeEmbeddedPostgres("issue watchdog routes", () => { }, 20_000); afterEach(async () => { + mockAdapterExecute.mockClear(); + runningProcesses.clear(); + await drainHeartbeatRunsToQuiescence(db, heartbeatService(db)); await db.delete(activityLog); await db.delete(issueComments); await db.delete(heartbeatRunEvents); @@ -57,6 +87,7 @@ describeEmbeddedPostgres("issue watchdog routes", () => { await db.delete(issueWatchdogs); await db.delete(issues); await db.delete(agents); + await db.delete(companySkills); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(companies); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 79b0db662f..aed926c578 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -263,6 +263,7 @@ import { touchHeartbeatRunRuntimeStatus, } from "./heartbeat-run-runtime-status.js"; import { + findMissingHotRestartSnapshotRunIds, readHotRestartIntent, removeHotRestartIntent, shouldHonorHotRestartIntentForProcess, @@ -9721,12 +9722,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (!intent.shutdownSnapshot) { logger.warn( - { previousServerPid: intent.previousServerPid }, + { + previousServerPid: intent.previousServerPid, + preflightActiveRunIds: intent.preflightActiveRunIds, + }, "hot-restart intent present but shutdown snapshot is missing; no runs can be adopted", ); } const candidates = intent.shutdownSnapshot?.activeRuns ?? []; - const currentRows = candidates.length > 0 + const missingSnapshotRunIds = findMissingHotRestartSnapshotRunIds(intent); + const reconciliationRunIds = [ + ...new Set([...candidates.map((run) => run.runId), ...missingSnapshotRunIds]), + ]; + const currentRows = reconciliationRunIds.length > 0 ? await db .select({ run: heartbeatRuns, @@ -9734,7 +9742,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }) .from(heartbeatRuns) .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) - .where(inArray(heartbeatRuns.id, candidates.map((run) => run.runId))) + .where(inArray(heartbeatRuns.id, reconciliationRunIds)) : []; const currentByRunId = new Map(currentRows.map((row) => [row.run.id, row])); @@ -9758,6 +9766,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) else skippedRunIds.push(candidate.runId); }; + for (const runId of missingSnapshotRunIds) { + const current = currentByRunId.get(runId); + if (!current) { + finalizedWhileDownRunIds.push(runId); + continue; + } + + const candidate = toHotRestartIntentRun(current); + if (current.run.status !== "running") { + classify(candidate, "finalized_while_down", `run_status_${current.run.status}`); + } else { + classify(candidate, "lost", "missing_shutdown_snapshot"); + } + } + + if (lostRunIds.length > 0) { + logger.error( + { previousServerPid: intent.previousServerPid, lostRunIds }, + "hot-restart shutdown snapshot omitted live preflight runs; reporting them as lost", + ); + } + for (const candidate of candidates) { const current = currentByRunId.get(candidate.runId); if (!current) { @@ -9868,7 +9898,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) skippedRunIds, runs: reportRuns, }); - await removeHotRestartIntent(); + await removeHotRestartIntent(undefined, intent); logger.info( { @@ -9877,6 +9907,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) adoptedRunIds, finalizedWhileDownRunIds, lostRunIds, + missingSnapshotRunIds, skippedRunIds, }, "hot-restart adoption report written", diff --git a/server/src/services/hot-restart.test.ts b/server/src/services/hot-restart.test.ts new file mode 100644 index 0000000000..497435805b --- /dev/null +++ b/server/src/services/hot-restart.test.ts @@ -0,0 +1,476 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + findMissingHotRestartSnapshotRunIds, + isObservedHotRestartTargetAlive, + readHotRestartIntent, + readProcessStartedAt, + removeHotRestartIntent, + resolveHotRestartIntentPath, + resolveLegacyHotRestartIntentPath, + writeHotRestartIntent, +} from "./hot-restart.js"; + +const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + +afterEach(() => { + if (originalInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId; +}); + +async function withTempHome(fn: (homeDir: string) => Promise) { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hot-restart-paths-")); + try { + return await fn(homeDir); + } finally { + await fs.rm(homeDir, { recursive: true, force: true }); + } +} + +describe("hot-restart path compatibility", () => { + it("reads Linux process start time from proc metadata", async () => { + await expect( + readProcessStartedAt(123, { + platform: "linux", + stat: async (target) => { + expect(target).toBe("/proc/123"); + return { + ctimeMs: Date.parse("2026-08-01T01:00:00.123Z"), + }; + }, + }), + ).resolves.toBe("2026-08-01T01:00:00.123Z"); + }); + + it("reads macOS process start time through ps", async () => { + await expect( + readProcessStartedAt(456, { + platform: "darwin", + runCommand: async (command, args) => { + expect([command, ...args]).toEqual([ + "ps", + "-o", + "lstart=", + "-p", + "456", + ]); + return "Fri Aug 1 01:02:03 2026\n"; + }, + }), + ).resolves.toBe(new Date("Fri Aug 1 01:02:03 2026").toISOString()); + }); + + it("reads Windows process start time through PowerShell", async () => { + await expect( + readProcessStartedAt(789, { + platform: "win32", + runCommand: async (command, args) => { + expect(command).toBe("powershell.exe"); + expect(args.at(-1)).toContain("Get-Process -Id 789"); + return "2026-08-01T01:02:03.456Z\r\n"; + }, + }), + ).resolves.toBe("2026-08-01T01:02:03.456Z"); + }); + + it("falls back from Windows PowerShell to pwsh", async () => { + const commands: string[] = []; + await expect( + readProcessStartedAt(790, { + platform: "win32", + runCommand: async (command) => { + commands.push(command); + if (command === "powershell.exe") throw new Error("not installed"); + return "2026-08-01T01:02:04.000Z\n"; + }, + }), + ).resolves.toBe("2026-08-01T01:02:04.000Z"); + expect(commands).toEqual(["powershell.exe", "pwsh.exe"]); + }); + + it("surfaces supported-platform process identity probe failures", async () => { + await expect(readProcessStartedAt(791, { + platform: "darwin", + runCommand: async () => { + throw new Error("ps unavailable"); + }, + })).rejects.toThrow("ps unavailable"); + }); + + it("distinguishes recycled PIDs and rejects unknown live claim classification", () => { + const intent = { + version: 1 as const, + requestedAt: "2026-08-01T01:05:00.000Z", + previousServerPid: 123, + previousServerIdentity: "server-boot-a", + previousServerStartedAt: "2026-08-01T01:00:00.000Z", + previousServerVersion: "old-version", + drainRequired: false, + requestedByRunId: null, + preflightActiveRunIds: [], + }; + + expect(isObservedHotRestartTargetAlive(intent, { + alive: true, + startedAt: "2026-08-01T01:00:00.000Z", + replacement: { + previousServerPid: 123, + previousServerIdentity: "server-boot-a", + }, + })).toBe(true); + expect(isObservedHotRestartTargetAlive(intent, { + alive: true, + startedAt: null, + replacement: { + previousServerPid: 123, + previousServerIdentity: "server-boot-b", + }, + })).toBe(false); + + const legacyIntent = { + ...intent, + previousServerIdentity: null, + previousServerStartedAt: null, + }; + expect(isObservedHotRestartTargetAlive(legacyIntent, { + alive: true, + startedAt: "2026-08-01T01:06:00.000Z", + })).toBe(false); + expect(() => isObservedHotRestartTargetAlive(legacyIntent, { + alive: true, + startedAt: null, + })).toThrow("Cannot establish process identity"); + expect(isObservedHotRestartTargetAlive(legacyIntent, { + alive: true, + startedAt: null, + replacement: { + previousServerPid: 123, + previousServerIdentity: "2026-08-01T01:04:00.000Z", + }, + })).toBe(true); + expect(isObservedHotRestartTargetAlive(legacyIntent, { + alive: true, + startedAt: null, + replacement: { + previousServerPid: 123, + previousServerIdentity: "2026-08-01T01:06:00.000Z", + }, + })).toBe(false); + expect(isObservedHotRestartTargetAlive(legacyIntent, { + alive: true, + startedAt: null, + replacement: { + previousServerPid: 123, + previousServerIdentity: null, + previousServerStartedAt: "2026-08-01T01:04:00.000Z", + }, + })).toBe(true); + expect(isObservedHotRestartTargetAlive(legacyIntent, { + alive: true, + startedAt: null, + replacement: { + previousServerPid: 123, + previousServerIdentity: null, + previousServerStartedAt: "2026-08-01T01:06:00.000Z", + }, + })).toBe(false); + }); + + it("refuses to create an unidentifiable process claim", async () => { + await withTempHome(async (homeDir) => { + await expect(writeHotRestartIntent({ + homeDir, + previousServerPid: 2_147_483_647, + previousServerStartedAt: null, + })).rejects.toThrow("process start time are unavailable"); + + await expect(fs.stat(resolveHotRestartIntentPath(homeDir))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fs.stat(resolveLegacyHotRestartIntentPath(homeDir))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }); + + it("reclaims a live recycled PID when server boot identities differ", async () => { + await withTempHome(async (homeDir) => { + process.env.PAPERCLIP_INSTANCE_ID = "blue"; + await writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + previousServerIdentity: "server-boot-a", + requestedByRunId: "blue-deploy", + }); + + process.env.PAPERCLIP_INSTANCE_ID = "green"; + await expect(writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + previousServerIdentity: "server-boot-b", + requestedByRunId: "green-deploy", + })).resolves.toMatchObject({ + previousServerIdentity: "server-boot-b", + requestedByRunId: "green-deploy", + }); + }); + }); + + it("writes both paths but does not merge a legacy snapshot from another request", async () => { + process.env.PAPERCLIP_INSTANCE_ID = "blue"; + + await withTempHome(async (homeDir) => { + await writeHotRestartIntent({ + homeDir, + previousServerPid: 101, + previousServerStartedAt: "2026-08-01T01:00:00.000Z", + requestedAt: new Date("2026-08-01T02:00:00.000Z"), + requestedByRunId: "blue-deploy", + preflightActiveRunIds: ["blue-run"], + }); + await expect(fs.stat(resolveHotRestartIntentPath(homeDir))).resolves.toBeDefined(); + await expect(fs.stat(resolveLegacyHotRestartIntentPath(homeDir))).resolves.toBeDefined(); + + const unrelatedLegacyIntent = { + version: 1, + requestedAt: "2026-08-01T02:00:01.000Z", + previousServerPid: 202, + previousServerVersion: "other-instance", + drainRequired: false, + requestedByRunId: "other-deploy", + shutdownSnapshot: { + capturedAt: "2026-08-01T02:00:02.000Z", + signal: "SIGTERM", + activeRuns: [], + }, + }; + await fs.writeFile( + resolveLegacyHotRestartIntentPath(homeDir), + `${JSON.stringify(unrelatedLegacyIntent, null, 2)}\n`, + "utf8", + ); + + await expect(readHotRestartIntent(homeDir)).resolves.toMatchObject({ + previousServerPid: 101, + requestedByRunId: "blue-deploy", + preflightActiveRunIds: ["blue-run"], + }); + expect((await readHotRestartIntent(homeDir))?.shutdownSnapshot).toBeUndefined(); + }); + }); + + it("does not let a non-default instance consume an uncorrelated legacy-only marker", async () => { + process.env.PAPERCLIP_INSTANCE_ID = "green"; + + await withTempHome(async (homeDir) => { + await fs.writeFile( + resolveLegacyHotRestartIntentPath(homeDir), + `${JSON.stringify({ + version: 1, + requestedAt: "2026-08-01T03:00:00.000Z", + previousServerPid: 303, + previousServerVersion: "legacy", + drainRequired: false, + requestedByRunId: null, + })}\n`, + "utf8", + ); + + await expect(readHotRestartIntent(homeDir)).resolves.toBeNull(); + }); + }); + + it("ignores a malformed legacy marker when the instance marker is valid", async () => { + process.env.PAPERCLIP_INSTANCE_ID = "blue"; + + await withTempHome(async (homeDir) => { + await writeHotRestartIntent({ + homeDir, + previousServerPid: 304, + previousServerStartedAt: "2026-08-01T03:00:00.000Z", + requestedAt: new Date("2026-08-01T03:30:00.000Z"), + preflightActiveRunIds: ["blue-run"], + }); + await fs.writeFile(resolveLegacyHotRestartIntentPath(homeDir), "not-json", "utf8"); + + await expect(readHotRestartIntent(homeDir)).resolves.toMatchObject({ + previousServerPid: 304, + preflightActiveRunIds: ["blue-run"], + }); + }); + }); + + it("does not overwrite another instance's active legacy handoff", async () => { + await withTempHome(async (homeDir) => { + process.env.PAPERCLIP_INSTANCE_ID = "blue"; + await writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + requestedAt: new Date("2026-08-01T03:40:00.000Z"), + requestedByRunId: "blue-deploy", + }); + + process.env.PAPERCLIP_INSTANCE_ID = "green"; + await expect(writeHotRestartIntent({ + homeDir, + previousServerPid: 502, + previousServerStartedAt: "2026-08-01T03:00:00.000Z", + requestedAt: new Date("2026-08-01T03:40:01.000Z"), + requestedByRunId: "green-deploy", + })).rejects.toMatchObject({ code: "EEXIST" }); + + const legacyIntent = JSON.parse( + await fs.readFile(resolveLegacyHotRestartIntentPath(homeDir), "utf8"), + ) as Record; + expect(legacyIntent).toMatchObject({ + previousServerPid: process.pid, + requestedByRunId: "blue-deploy", + }); + await expect(fs.stat(resolveHotRestartIntentPath(homeDir))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }); + + it("reclaims an abandoned legacy handoff after its target process exits", async () => { + await withTempHome(async (homeDir) => { + process.env.PAPERCLIP_INSTANCE_ID = "blue"; + await writeHotRestartIntent({ + homeDir, + previousServerPid: 2_147_483_647, + previousServerStartedAt: "2026-08-01T03:00:00.000Z", + requestedAt: new Date("2026-08-01T03:50:00.000Z"), + requestedByRunId: "abandoned-deploy", + }); + + process.env.PAPERCLIP_INSTANCE_ID = "green"; + await expect(writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + requestedAt: new Date("2026-08-01T03:51:00.000Z"), + requestedByRunId: "green-deploy", + })).resolves.toMatchObject({ requestedByRunId: "green-deploy" }); + + await expect(readHotRestartIntent(homeDir)).resolves.toMatchObject({ + previousServerPid: process.pid, + requestedByRunId: "green-deploy", + }); + }); + }); + + it("reclaims an abandoned handoff when its PID belongs to a newer process", async () => { + await withTempHome(async (homeDir) => { + process.env.PAPERCLIP_INSTANCE_ID = "blue"; + await writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + requestedAt: new Date("2020-01-01T00:00:00.000Z"), + requestedByRunId: "expired-deploy", + }); + const legacyPath = resolveLegacyHotRestartIntentPath(homeDir); + const legacyIntent = JSON.parse( + await fs.readFile(legacyPath, "utf8"), + ) as Record; + delete legacyIntent.previousServerStartedAt; + await fs.writeFile( + legacyPath, + `${JSON.stringify(legacyIntent, null, 2)}\n`, + "utf8", + ); + + process.env.PAPERCLIP_INSTANCE_ID = "green"; + await expect(writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + requestedByRunId: "green-deploy", + })).resolves.toMatchObject({ requestedByRunId: "green-deploy" }); + + await expect(readHotRestartIntent(homeDir)).resolves.toMatchObject({ + requestedByRunId: "green-deploy", + }); + }); + }); + + it("keeps an old handoff while its original target process is alive", async () => { + await withTempHome(async (homeDir) => { + process.env.PAPERCLIP_INSTANCE_ID = "blue"; + await writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + requestedAt: new Date(), + requestedByRunId: "blue-deploy", + }); + + vi.useFakeTimers({ now: Date.now() + 10 * 60_000 }); + try { + process.env.PAPERCLIP_INSTANCE_ID = "green"; + await expect(writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + requestedByRunId: "green-deploy", + })).rejects.toMatchObject({ code: "EEXIST" }); + } finally { + vi.useRealTimers(); + } + }); + }); + + it("does not let matching cleanup delete replacement intent markers", async () => { + await withTempHome(async (homeDir) => { + process.env.PAPERCLIP_INSTANCE_ID = "blue"; + const abandonedIntent = await writeHotRestartIntent({ + homeDir, + previousServerPid: 2_147_483_647, + previousServerStartedAt: "2026-08-01T03:00:00.000Z", + requestedAt: new Date("2026-08-01T03:55:00.000Z"), + requestedByRunId: "abandoned-deploy", + }); + + await Promise.all([ + removeHotRestartIntent(homeDir, abandonedIntent), + writeHotRestartIntent({ + homeDir, + previousServerPid: process.pid, + requestedAt: new Date("2026-08-01T03:56:00.000Z"), + requestedByRunId: "replacement-deploy", + }), + ]); + + const legacyIntent = JSON.parse( + await fs.readFile(resolveLegacyHotRestartIntentPath(homeDir), "utf8"), + ) as Record; + expect(legacyIntent).toMatchObject({ requestedByRunId: "replacement-deploy" }); + await expect(readHotRestartIntent(homeDir)).resolves.toMatchObject({ + requestedByRunId: "replacement-deploy", + }); + }); + }); + + it("treats every preflight run omitted from the shutdown snapshot as missing", () => { + expect(findMissingHotRestartSnapshotRunIds({ + version: 1, + requestedAt: "2026-08-01T04:00:00.000Z", + previousServerPid: 404, + previousServerVersion: "old-version", + drainRequired: false, + requestedByRunId: null, + preflightActiveRunIds: ["captured-run", "missing-run"], + shutdownSnapshot: { + capturedAt: "2026-08-01T04:00:01.000Z", + signal: "SIGTERM", + activeRuns: [{ + runId: "captured-run", + companyId: "company", + agentId: "agent", + adapterType: "codex_local", + status: "running", + processPid: 405, + processGroupId: null, + issueId: "issue", + }], + }, + })).toEqual(["missing-run"]); + }); +}); diff --git a/server/src/services/hot-restart.ts b/server/src/services/hot-restart.ts index 25b7f4647f..02700ff096 100644 --- a/server/src/services/hot-restart.ts +++ b/server/src/services/hot-restart.ts @@ -1,9 +1,20 @@ +import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; -import { resolvePaperclipInstanceRoot } from "../home-paths.js"; +import { + resolvePaperclipHomeDir, + resolvePaperclipInstanceId, + resolvePaperclipInstanceRoot, +} from "../home-paths.js"; export const HOT_RESTART_INTENT_FILENAME = "hot-restart-intent.json"; export const HOT_RESTART_REPORT_FILENAME = "hot-restart-report.json"; +const HOT_RESTART_LOCK_SUFFIX = ".lock"; +const HOT_RESTART_LOCK_STALE_MS = 30_000; +const HOT_RESTART_LOCK_TIMEOUT_MS = 10_000; + +type ProcessCommandRunner = (command: string, args: string[]) => Promise; +type ProcessStatReader = (target: string) => Promise<{ ctimeMs: number }>; export type HotRestartIntentRun = { runId: string; @@ -20,9 +31,12 @@ export type HotRestartIntent = { version: 1; requestedAt: string; previousServerPid: number; + previousServerIdentity?: string | null; + previousServerStartedAt?: string | null; previousServerVersion: string | null; drainRequired: boolean; requestedByRunId: string | null; + preflightActiveRunIds: string[]; shutdownSnapshot?: { capturedAt: string; signal: "SIGINT" | "SIGTERM"; @@ -59,10 +73,18 @@ function resolveHotRestartPath(filename: string, homeDir?: string) { return path.join(resolvePaperclipInstanceRoot({ homeDir }), filename); } +function resolveLegacyHotRestartPath(filename: string, homeDir?: string) { + return path.join(resolvePaperclipHomeDir(homeDir), filename); +} + export function resolveHotRestartIntentPath(homeDir?: string) { return resolveHotRestartPath(HOT_RESTART_INTENT_FILENAME, homeDir); } +export function resolveLegacyHotRestartIntentPath(homeDir?: string) { + return resolveLegacyHotRestartPath(HOT_RESTART_INTENT_FILENAME, homeDir); +} + export function resolveHotRestartReportPath(homeDir?: string) { return resolveHotRestartPath(HOT_RESTART_REPORT_FILENAME, homeDir); } @@ -74,6 +96,17 @@ async function writeJsonFileAtomic(filePath: string, value: unknown) { await fs.rename(tempPath, filePath); } +async function writeJsonFileExclusiveAtomic(filePath: string, value: unknown) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + try { + await fs.link(tempPath, filePath); + } finally { + await fs.unlink(tempPath).catch(() => undefined); + } +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -90,6 +123,261 @@ function asBoolean(value: unknown): boolean { return value === true; } +function asDateString(value: unknown): string | null { + const candidate = asString(value); + if (!candidate) return null; + const timestamp = Date.parse(candidate); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.map(asString).filter((entry): entry is string => entry !== null))]; +} + +function isProcessAlive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM"; + } +} + +function runProcessCommand(command: string, args: string[]) { + return new Promise((resolve, reject) => { + execFile( + command, + args, + { + encoding: "utf8", + timeout: 1_500, + windowsHide: true, + }, + (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }, + ); + }); +} + +export async function readProcessStartedAt( + pid: number, + options: { + platform?: NodeJS.Platform; + stat?: ProcessStatReader; + runCommand?: ProcessCommandRunner; + } = {}, +) { + const platform = options.platform ?? process.platform; + const stat = options.stat ?? fs.stat; + const runCommand = options.runCommand ?? runProcessCommand; + + if (platform === "linux") { + const processStat = await stat(`/proc/${pid}`); + return new Date(processStat.ctimeMs).toISOString(); + } + + if (["darwin", "freebsd", "openbsd", "aix", "sunos"].includes(platform)) { + const stdout = await runCommand("ps", ["-o", "lstart=", "-p", String(pid)]); + const startedAt = asDateString(stdout.trim()); + if (!startedAt) { + throw new Error(`Could not parse ${platform} process start time for PID ${pid}`); + } + return startedAt; + } + + if (platform === "win32") { + const script = [ + `$process = Get-Process -Id ${pid} -ErrorAction Stop`, + "$process.StartTime.ToUniversalTime().ToString('o')", + ].join("; "); + let stdout: string; + try { + stdout = await runCommand("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + } catch { + stdout = await runCommand("pwsh.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + } + const startedAt = asDateString(stdout.trim()); + if (!startedAt) { + throw new Error(`Could not parse Windows process start time for PID ${pid}`); + } + return startedAt; + } + + return null; +} + +export function isObservedHotRestartTargetAlive( + intent: HotRestartIntent, + observation: { + alive: boolean; + startedAt: string | null; + replacement?: Pick< + HotRestartIntent, + "previousServerPid" | "previousServerIdentity" | "previousServerStartedAt" + >; + }, +) { + if (!observation.alive) return false; + + if (observation.replacement?.previousServerPid === intent.previousServerPid) { + if ( + observation.replacement.previousServerIdentity + && intent.previousServerIdentity + ) { + return observation.replacement.previousServerIdentity + === intent.previousServerIdentity; + } + + if ( + observation.replacement.previousServerIdentity + && !intent.previousServerIdentity + ) { + const replacementStartedAt = Date.parse( + observation.replacement.previousServerIdentity, + ); + const requestedAt = Date.parse(intent.requestedAt); + if (Number.isFinite(replacementStartedAt) && Number.isFinite(requestedAt)) { + // The health endpoint's processStartedAt value is also the current + // server boot identity. If that process started after an older marker + // was requested, the shared numeric PID was necessarily recycled. + return replacementStartedAt <= requestedAt; + } + } + + if (!intent.previousServerIdentity) { + const replacementStartedAt = observation.replacement.previousServerStartedAt + ? Date.parse(observation.replacement.previousServerStartedAt) + : Number.NaN; + const requestedAt = Date.parse(intent.requestedAt); + if (Number.isFinite(replacementStartedAt) && Number.isFinite(requestedAt)) { + return replacementStartedAt <= requestedAt; + } + } + } + + const observedStartedAt = observation.startedAt + ? Date.parse(observation.startedAt) + : Number.NaN; + const recordedStartedAt = intent.previousServerStartedAt + ? Date.parse(intent.previousServerStartedAt) + : Number.NaN; + if (Number.isFinite(observedStartedAt) && Number.isFinite(recordedStartedAt)) { + return observedStartedAt === recordedStartedAt; + } + + const requestedAt = Date.parse(intent.requestedAt); + if (Number.isFinite(observedStartedAt) && Number.isFinite(requestedAt)) { + // Older markers have no recorded process start. A process that started + // after the request necessarily reused the marker's numeric PID. + return observedStartedAt <= requestedAt; + } + + throw new Error( + `Cannot establish process identity for live hot-restart target PID ${intent.previousServerPid}`, + ); +} + +async function isOriginalServerProcessAlive( + intent: HotRestartIntent, + replacement: HotRestartIntent, +) { + const alive = isProcessAlive(intent.previousServerPid); + const startedAt = alive + ? await readProcessStartedAt(intent.previousServerPid) + : null; + return isObservedHotRestartTargetAlive(intent, { + alive, + startedAt, + replacement, + }); +} + +async function removeStaleHotRestartLock(lockDir: string) { + let shouldRemove = false; + try { + const owner = JSON.parse( + await fs.readFile(path.join(lockDir, "owner.json"), "utf8"), + ) as { pid?: unknown; createdAt?: unknown }; + const pid = typeof owner.pid === "number" ? owner.pid : 0; + const createdAt = typeof owner.createdAt === "string" + ? Date.parse(owner.createdAt) + : Number.NaN; + const ageMs = Number.isFinite(createdAt) + ? Date.now() - createdAt + : HOT_RESTART_LOCK_STALE_MS + 1; + shouldRemove = !isProcessAlive(pid) || ageMs > HOT_RESTART_LOCK_STALE_MS; + } catch { + const stat = await fs.stat(lockDir).catch(() => null); + shouldRemove = !stat + || Date.now() - stat.mtimeMs > HOT_RESTART_LOCK_STALE_MS; + } + if (!shouldRemove) return false; + await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined); + return true; +} + +async function acquireHotRestartPathLock(filePath: string) { + const lockDir = `${filePath}${HOT_RESTART_LOCK_SUFFIX}`; + const deadline = Date.now() + HOT_RESTART_LOCK_TIMEOUT_MS; + while (true) { + try { + await fs.mkdir(lockDir); + try { + await fs.writeFile( + path.join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, + "utf8", + ); + } catch (error) { + await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + return async () => { + await fs.rm(lockDir, { recursive: true, force: true }); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (await removeStaleHotRestartLock(lockDir)) continue; + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for hot-restart compatibility lock at ${lockDir}`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } +} + +async function withHotRestartPathLock(filePath: string, operation: () => Promise) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const release = await acquireHotRestartPathLock(filePath); + try { + return await operation(); + } finally { + await release(); + } +} + +function isSameHotRestartRequest(left: HotRestartIntent, right: HotRestartIntent) { + return left.requestedAt === right.requestedAt + && left.previousServerPid === right.previousServerPid + && left.drainRequired === right.drainRequired + && left.requestedByRunId === right.requestedByRunId; +} + function parseRun(value: unknown): HotRestartIntentRun | null { if (!isRecord(value)) return null; const runId = asString(value.runId); @@ -120,9 +408,12 @@ export function parseHotRestartIntent(value: unknown): HotRestartIntent | null { version: 1, requestedAt, previousServerPid, + previousServerIdentity: asString(value.previousServerIdentity), + previousServerStartedAt: asDateString(value.previousServerStartedAt), previousServerVersion: asString(value.previousServerVersion), drainRequired: asBoolean(value.drainRequired), requestedByRunId: asString(value.requestedByRunId), + preflightActiveRunIds: asStringArray(value.preflightActiveRunIds), }; const snapshot = isRecord(value.shutdownSnapshot) ? value.shutdownSnapshot : null; @@ -140,9 +431,9 @@ export function parseHotRestartIntent(value: unknown): HotRestartIntent | null { return intent; } -export async function readHotRestartIntent(homeDir?: string) { +async function readHotRestartIntentAtPath(filePath: string) { try { - const raw = await fs.readFile(resolveHotRestartIntentPath(homeDir), "utf8"); + const raw = await fs.readFile(filePath, "utf8"); return parseHotRestartIntent(JSON.parse(raw)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; @@ -150,23 +441,85 @@ export async function readHotRestartIntent(homeDir?: string) { } } +export async function readHotRestartIntent(homeDir?: string) { + const instanceIntent = await readHotRestartIntentAtPath(resolveHotRestartIntentPath(homeDir)); + let legacyIntent: HotRestartIntent | null; + try { + legacyIntent = await readHotRestartIntentAtPath(resolveLegacyHotRestartIntentPath(homeDir)); + } catch (error) { + if (instanceIntent) return instanceIntent; + throw error; + } + + if (!instanceIntent) { + // The home-root marker predates instances. Only the default instance may + // consume it without an instance-root request to correlate against. + return resolvePaperclipInstanceId() === "default" ? legacyIntent : null; + } + if (!legacyIntent || !isSameHotRestartRequest(instanceIntent, legacyIntent)) { + return instanceIntent; + } + + // A pre-instance server rewrites only the legacy marker when it captures + // its shutdown snapshot. Preserve the instance-scoped request fields while + // importing that snapshot only after the immutable request identity matches. + return legacyIntent.shutdownSnapshot + ? { ...instanceIntent, shutdownSnapshot: legacyIntent.shutdownSnapshot } + : instanceIntent; +} + +export function findMissingHotRestartSnapshotRunIds(intent: HotRestartIntent) { + const snapshotRunIds = new Set(intent.shutdownSnapshot?.activeRuns.map((run) => run.runId) ?? []); + return intent.preflightActiveRunIds.filter((runId) => !snapshotRunIds.has(runId)); +} + export async function writeHotRestartIntent(input: { previousServerPid: number; + previousServerIdentity?: string | null; + previousServerStartedAt?: string | null; previousServerVersion?: string | null; drainRequired?: boolean; requestedByRunId?: string | null; + preflightActiveRunIds?: string[]; requestedAt?: Date; homeDir?: string; }) { + const previousServerStartedAt = input.previousServerStartedAt === undefined + ? await readProcessStartedAt(input.previousServerPid) + : asDateString(input.previousServerStartedAt); + const previousServerIdentity = asString(input.previousServerIdentity); + if (!previousServerIdentity && !previousServerStartedAt) { + throw new Error( + `Cannot create hot-restart intent for PID ${input.previousServerPid}: ` + + "server boot identity and operating-system process start time are unavailable", + ); + } const intent: HotRestartIntent = { version: 1, requestedAt: (input.requestedAt ?? new Date()).toISOString(), previousServerPid: input.previousServerPid, + previousServerIdentity, + previousServerStartedAt, previousServerVersion: input.previousServerVersion ?? null, drainRequired: input.drainRequired ?? false, requestedByRunId: input.requestedByRunId ?? null, + preflightActiveRunIds: asStringArray(input.preflightActiveRunIds), }; - await writeJsonFileAtomic(resolveHotRestartIntentPath(input.homeDir), intent); + const instancePath = resolveHotRestartIntentPath(input.homeDir); + const legacyPath = resolveLegacyHotRestartIntentPath(input.homeDir); + // The legacy location is shared by every instance under PAPERCLIP_HOME. + // Claim it without replacement so concurrent staged restarts fail closed + // instead of making the first old server consume another instance's PID. + await withHotRestartPathLock(legacyPath, () => claimLegacyHotRestartIntent(legacyPath, intent)); + try { + await withHotRestartPathLock(instancePath, () => writeJsonFileAtomic(instancePath, intent)); + } catch (error) { + await withHotRestartPathLock( + legacyPath, + () => removeMatchingHotRestartIntent(legacyPath, intent), + ).catch(() => undefined); + throw error; + } return intent; } @@ -185,7 +538,15 @@ export async function writeHotRestartShutdownSnapshot(input: { activeRuns: input.activeRuns, }, }; - await writeJsonFileAtomic(resolveHotRestartIntentPath(input.homeDir), updated); + const instancePath = resolveHotRestartIntentPath(input.homeDir); + await withHotRestartPathLock(instancePath, () => writeJsonFileAtomic(instancePath, updated)); + const legacyPath = resolveLegacyHotRestartIntentPath(input.homeDir); + await withHotRestartPathLock(legacyPath, async () => { + const legacyIntent = await readHotRestartIntentAtPath(legacyPath).catch(() => null); + if (legacyIntent && isSameHotRestartRequest(legacyIntent, input.intent)) { + await writeJsonFileAtomic(legacyPath, updated); + } + }); return updated; } @@ -194,14 +555,54 @@ export async function writeHotRestartReport(report: HotRestartReport, homeDir?: return report; } -export async function removeHotRestartIntent(homeDir?: string) { +async function removeMatchingHotRestartIntent(filePath: string, expected?: HotRestartIntent) { try { - await fs.unlink(resolveHotRestartIntentPath(homeDir)); + if (expected) { + const current = await readHotRestartIntentAtPath(filePath); + if (!current || !isSameHotRestartRequest(current, expected)) return; + } + await fs.unlink(filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } +async function claimLegacyHotRestartIntent(filePath: string, intent: HotRestartIntent) { + try { + await writeJsonFileExclusiveAtomic(filePath, intent); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + + const existing = await readHotRestartIntentAtPath(filePath).catch(() => null); + if ( + !existing + || await isOriginalServerProcessAlive(existing, intent) + ) { + throw error; + } + + // An interrupted restart can leave the shared claim behind after its + // target server exits. Remove only that exact abandoned request, then + // compete normally for a fresh exclusive claim. + await removeMatchingHotRestartIntent(filePath, existing); + await writeJsonFileExclusiveAtomic(filePath, intent); + } +} + +export async function removeHotRestartIntent(homeDir?: string, expected?: HotRestartIntent) { + const instancePath = resolveHotRestartIntentPath(homeDir); + await withHotRestartPathLock( + instancePath, + () => removeMatchingHotRestartIntent(instancePath, expected), + ); + const legacyPath = resolveLegacyHotRestartIntentPath(homeDir); + await withHotRestartPathLock( + legacyPath, + () => removeMatchingHotRestartIntent(legacyPath, expected), + ); +} + export function shouldHonorHotRestartIntentForProcess( intent: HotRestartIntent, pid = process.pid,