Files
PaperClipAI/server/src/services/native-runtime/native-restart-recovery.test.ts
T
DottaandPaperclip db11d899cf fix(native): recover remote sessions while preserving warm runs (#16451)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native agents keep remote processes and conversation history across
warm turns.
> - A completed turn can leave handoff authority only in controller
memory.
> - Losing that controller can block a follow-up even while its sandbox
survives.
> - This pull request verifies durable ownership and reconnects the
exact prior process before handing it to the next run.
> - Healthy follow-ups stay warm. Recovery preserves saved work without
replaying a completed turn.

## Linked Issues or Issue Description

**What happened?**

A remote warm turn can finish without suspended backup authority. A
replacement controller rejects that prior owner even when its exact
process and completed checkpoint remain. A single unavailable identity
probe can also stop an otherwise valid reattachment.

**Expected behavior**

Keep healthy follow-ups warm. Recover a completed session after proving
that its controller stopped and that the successor holds current
authority. Reuse compatible processes; otherwise save and resume the
conversation. Retry unavailable identity reads within one shared
deadline.

**Steps to reproduce**

Use `doc/testing-native-remote-continuity.md`. Its Linux cases execute
real turns through PostgreSQL, TLS ingress, and the Rust runner. They
kill a separate controller process, recover old checkpoint metadata, and
change process configuration. The provider is deterministic.

Related: #16432 and #16441 fix live warm-owner handoffs. #16341 adds
recovery diagnostics. #14695 covers provider process retention.

## What Changed

- Preserve warm process reuse between healthy remote turns.
- Verify a committed prior result, stopped controller, matching
checkpoint and sandbox, and current task/coordinator ownership before
recovering an orphaned warm session.
- Reconnect through the prior run route, then use the existing
authenticated run attachment to change authority.
- Persist a process-configuration digest. Close and save recovered
sessions when old or changed configuration cannot support reuse.
- Retry unavailable state, marker, and process reads within one
30-second deadline. Keep signals and definitive identity failures
single-attempt.
- Record closed recovery diagnostics, including unreadable or malformed
saved state.
- Retire the recovery-only ownership check after verified attachment, so
later warm turns use their current authority.
- Add real-process Linux regressions and refusal controls.
- Expose the existing source-only file-recovery form for retained native
holds, with the same board authority, evidence, and exact stop-receipt
requirements as legacy holds. Recording recovered files does not change
task status or replay a run.

## Verification

- `pnpm -r typecheck` passed; the server check was repeated for the
third-turn fix.
- `pnpm build` passed after the final UI change. The UI typecheck, token
gates, and map checks also pass.
- Four Linux continuity cases passed: warm reuse, controller loss,
legacy checkpoint metadata, and changed configuration. Each creates one
conversation and starts exactly three turns, including a healthy
follow-up after recovery. The fixture uses expired prior leases; the
third turn keeps the runner and provider processes.
- Greptile caught the retained recovery gate; the third-turn regression
now covers it. An initial test omitted the heartbeat’s saved input and
left a test process after failure; the fixture was corrected and its
disposable container reset before the passing run.
- The current focused executor/restart group passes all 749 tests,
including the original failure classifications.
- Native file-recovery and UI tests pass (52 tests), including blocked
tasks and both native and legacy holds.
- After merging current master and retaining its equivalent CI
expression check, the CI policy tests pass (15 tests) and all four
three-turn Linux continuity cases pass again on
`dec418ae55ff21b4fb067132613658cce445f181`.
- `pnpm test:run` completed its general server group with 20,572 passes,
103 skips, and three failures: the skill-audit child process and two
HTTP test timeouts. The same three suites then passed in isolation (89
tests) without code changes. That local command stopped before later
groups; the complete final-head CI, including general, serialized,
Runner, build, and browser gates, is green.
- Greptile reviewed the combined head
`dec418ae55ff21b4fb067132613658cce445f181` at 5/5 with no actionable
findings; both earlier findings are resolved. All final-head CI gates
and the security scan passed. Staging deployment and retained-incident
disposition remain deployment acceptance work.

## Risks

- Recovery requires exact durable evidence. Live conflicting
controllers, replacement sandboxes, Stop/pause, and uncertain effects
must remain blocked.
- After controller loss, tool-contract or process-configuration changes
can restart the provider. Normal healthy follow-ups retain their
processes.
- The new idle-recovery path has Linux fake-provider evidence. Live
Codex/OpenCode and retained quarantined-state recovery still need
staging qualification.
- No database schema, telemetry, or runner protocol changes.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
execution, and browser tools. The session does not expose a more
specific model version or context-window size.

## 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 (targeted checks and
isolated reruns; full-run limitation recorded above)
- [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 on
`dec418ae55ff21b4fb067132613658cce445f181`
- [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 <noreply@paperclip.ing>
2026-10-15 13:26:09 -05:00

365 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
classifyNativeRunnerRecoveryEvidence,
evaluateNativeControllerTakeover,
evaluateNativeProviderProcesses,
nextNativeProviderAttempt,
} from "./native-restart-recovery.js";
describe("native restart recovery classification", () => {
it("does not reopen a failed checkpoint or reset an exhausted provider budget", () => {
const evidence = {
runnerPidAlive: false,
runnerGroupAlive: false,
processStartMatches: false,
hasCheckpoint: true,
hasProviderEvidence: true,
};
expect(
classifyNativeRunnerRecoveryEvidence({
...evidence,
checkpointFailed: true,
}),
).toMatchObject({
claimKind: null,
reason: "provider_checkpoint_permanently_failed",
});
expect(
classifyNativeRunnerRecoveryEvidence({ ...evidence, providerAttempt: 3 }),
).toMatchObject({
claimKind: null,
reason: "execution_recovery_budget_exhausted",
});
});
it("keeps controller-only recovery out of the provider retry budget", () => {
expect(nextNativeProviderAttempt(2, "reattach_existing_runner")).toBe(2);
expect(nextNativeProviderAttempt(2, "reattach_remote_runner")).toBe(2);
expect(nextNativeProviderAttempt(2, "bootstrap_incomplete")).toBe(3);
expect(nextNativeProviderAttempt(2, "resume_dead_runner")).toBe(3);
});
it("verifies remote ownership in the sandbox instead of interpreting its PIDs locally", () => {
const evidence = {
remoteSandbox: true,
runnerPidAlive: true,
runnerGroupAlive: true,
processStartMatches: false,
knownProviderProcessAlive: true,
knownProviderProcessIdentityAmbiguous: true,
hasCheckpoint: true,
checkpointIdentityMatches: true,
hasProviderEvidence: true,
providerAttempt: 3,
};
expect(classifyNativeRunnerRecoveryEvidence(evidence).claimKind).toBe(
"reattach_remote_runner",
);
expect(
classifyNativeRunnerRecoveryEvidence({
...evidence,
checkpointIdentityMatches: false,
}).claimKind,
).toBeNull();
expect(
classifyNativeRunnerRecoveryEvidence({
...evidence,
checkpointFailed: true,
}).claimKind,
).toBeNull();
expect(
classifyNativeRunnerRecoveryEvidence({
...evidence,
hasCheckpoint: false,
}).claimKind,
).toBeNull();
});
it.each([
{
name: "reattaches an exact live runner",
evidence: {
runnerPidAlive: true,
runnerGroupAlive: true,
processStartMatches: true,
hasCheckpoint: true,
hasProviderEvidence: true,
},
expected: "reattach_existing_runner",
},
{
name: "resumes a dead checkpointed runner",
evidence: {
runnerPidAlive: false,
runnerGroupAlive: false,
processStartMatches: false,
hasCheckpoint: true,
hasProviderEvidence: true,
},
expected: "resume_dead_runner",
},
{
name: "retries an incomplete bootstrap on the same run",
evidence: {
runnerPidAlive: false,
runnerGroupAlive: false,
processStartMatches: false,
hasCheckpoint: false,
hasProviderEvidence: false,
},
expected: "bootstrap_incomplete",
},
{
name: "blocks a recycled live PID",
evidence: {
runnerPidAlive: true,
runnerGroupAlive: true,
processStartMatches: false,
hasCheckpoint: true,
hasProviderEvidence: true,
},
expected: null,
},
{
name: "blocks ambiguous checkpoint evidence",
evidence: {
runnerPidAlive: false,
runnerGroupAlive: false,
processStartMatches: false,
hasCheckpoint: true,
hasProviderEvidence: false,
},
expected: null,
},
{
name: "blocks a checkpoint bound to a different native identity",
evidence: {
runnerPidAlive: false,
runnerGroupAlive: false,
processStartMatches: false,
hasCheckpoint: true,
checkpointIdentityMatches: false,
hasProviderEvidence: true,
},
expected: null,
},
{
name: "blocks while a known provider process outlives its runner",
evidence: {
runnerPidAlive: false,
runnerGroupAlive: false,
processStartMatches: false,
knownProviderProcessAlive: true,
hasCheckpoint: true,
hasProviderEvidence: true,
},
expected: null,
},
{
name: "blocks while a live provider PID has no durable fingerprint",
evidence: {
runnerPidAlive: false,
runnerGroupAlive: false,
processStartMatches: false,
knownProviderProcessIdentityAmbiguous: true,
hasCheckpoint: true,
hasProviderEvidence: true,
},
expected: null,
},
])("$name", ({ evidence, expected }) => {
expect(classifyNativeRunnerRecoveryEvidence(evidence).claimKind).toBe(
expected,
);
});
});
describe("native controller takeover fencing", () => {
const now = new Date("2026-09-04T12:00:00.000Z");
const recordedStart = new Date("2026-09-04T11:00:00.000Z");
function owner(
overrides: Partial<{
leaseOwner: string | null;
leaseExpiresAt: Date | null;
controllerPid: number | null;
controllerProcessStartedAt: Date | null;
}> = {},
) {
return {
leaseOwner: "old-controller",
leaseExpiresAt: new Date("2026-09-04T12:20:00.000Z"),
controllerPid: 123,
controllerProcessStartedAt: recordedStart,
...overrides,
};
}
it("takes over immediately when the exact prior controller process died", async () => {
await expect(
evaluateNativeControllerTakeover({
owner: owner(),
now,
isProcessAlive: () => false,
}),
).resolves.toEqual({
allowed: true,
reason: "controller_process_dead",
});
});
it.each([true, false])("requires a stopped idle controller even after its run lease was released: alive=%s", async alive => {
await expect(evaluateNativeControllerTakeover({
owner: owner({ leaseOwner: null, leaseExpiresAt: null }), now, requireStoppedController: true,
isProcessAlive: () => alive, readProcessStartedAt: async () => recordedStart,
})).resolves.toEqual({ allowed: !alive, reason: alive ? "controller_still_alive" : "controller_process_dead" });
});
it("takes over a recycled controller PID", async () => {
await expect(
evaluateNativeControllerTakeover({
owner: owner(),
now,
isProcessAlive: () => true,
readProcessStartedAt: async () => new Date("2026-09-04T11:30:00.000Z"),
}),
).resolves.toEqual({
allowed: true,
reason: "controller_pid_recycled",
});
});
it("does not treat a sub-second process-start precision difference as PID reuse", async () => {
await expect(
evaluateNativeControllerTakeover({
owner: owner({
controllerProcessStartedAt: new Date("2026-09-04T11:00:00.456Z"),
}),
now,
isProcessAlive: () => true,
readProcessStartedAt: async () => new Date("2026-09-04T11:00:00.000Z"),
}),
).resolves.toEqual({
allowed: false,
reason: "controller_still_alive",
});
});
it("does not steal a live controller lease", async () => {
const readStartedAt = vi.fn(async () => recordedStart);
await expect(
evaluateNativeControllerTakeover({
owner: owner(),
now,
isProcessAlive: () => true,
readProcessStartedAt: readStartedAt,
}),
).resolves.toEqual({
allowed: false,
reason: "controller_still_alive",
});
expect(readStartedAt).toHaveBeenCalledWith(123);
});
it("does not let lease expiry bypass a live controller fence", async () => {
const isProcessAlive = vi.fn(() => true);
await expect(
evaluateNativeControllerTakeover({
owner: owner({
leaseExpiresAt: new Date("2026-09-04T11:59:59.000Z"),
}),
now,
isProcessAlive,
readProcessStartedAt: async () => recordedStart,
}),
).resolves.toEqual({ allowed: false, reason: "controller_still_alive" });
expect(isProcessAlive).toHaveBeenCalledWith(123);
});
it("takes over an expired lease after proving the controller died", async () => {
await expect(
evaluateNativeControllerTakeover({
owner: owner({
leaseExpiresAt: new Date("2026-09-04T11:59:59.000Z"),
}),
now,
isProcessAlive: () => false,
}),
).resolves.toEqual({
allowed: true,
reason: "expired_lease_controller_process_dead",
});
});
it("keeps a coordinated previous controller fenced until that exact process exits", async () => {
await expect(
evaluateNativeControllerTakeover({
owner: owner(),
now,
coordinatedPreviousController: {
pid: 123,
processStartedAt: recordedStart,
},
isProcessAlive: () => true,
readProcessStartedAt: async () => recordedStart,
}),
).resolves.toEqual({
allowed: false,
reason: "coordinated_controller_still_alive",
});
});
});
describe("native provider process fencing", () => {
const recordedStart = new Date("2026-09-04T11:00:00.000Z");
it("does not mistake a recycled provider PID for the old live provider", async () => {
await expect(
evaluateNativeProviderProcesses({
identities: [{ pid: 456, processStartedAt: recordedStart }],
isProcessAlive: () => true,
readProcessStartedAt: async () => new Date("2026-09-04T11:30:00.000Z"),
}),
).resolves.toEqual({
knownPids: [456],
livePids: [],
ambiguousLivePids: [],
recycledPids: [456],
});
});
it("fails closed when a live provider has no comparable fingerprint", async () => {
await expect(
evaluateNativeProviderProcesses({
identities: [{ pid: 456, processStartedAt: null }],
isProcessAlive: () => true,
readProcessStartedAt: async () => recordedStart,
}),
).resolves.toMatchObject({
livePids: [],
ambiguousLivePids: [456],
recycledPids: [],
});
});
it("fails closed on a sub-second provider process-start precision difference", async () => {
await expect(
evaluateNativeProviderProcesses({
identities: [
{
pid: 456,
processStartedAt: new Date("2026-09-04T11:00:00.456Z"),
},
],
isProcessAlive: () => true,
readProcessStartedAt: async () => new Date("2026-09-04T11:00:00.000Z"),
}),
).resolves.toEqual({
knownPids: [456],
livePids: [],
ambiguousLivePids: [456],
recycledPids: [],
});
});
});