Files
PaperClipAI/server/src/services/owner-proof.ts
T
DottaandClaude Opus 5.5 cbf811cd3b fix(recovery): prove a replaced deployment's copyback owner gone automatically (#19913)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs save their workspace after the result is accepted, under
a copyback owner receipt
> - A deploy can restart the server while that save is in progress
> - The new server runs in a new container, so it cannot see the old
process and fails closed
> - The run then waits on a board action that asks a person to verify
the previous controller
> - Recoveries must continue without a human step, and copy must not ask
anyone to inspect a server
> - This pull request adds a deployment-level rule to the shared
owner-liveness primitive and uses it in workspace finalization
> - The benefit is that a save interrupted by a deploy resumes on its
own, with no second writer

## Linked Issues or Issue Description

**What happened?**
A deploy restarted the server while three runs were saving their
accepted results. Each run kept a copyback owner receipt that names the
old Railway deployment on another container. The restarted server
reported `native_workspace_finalization_owner_unverified`, owned by the
board, with the next action "Verify the previous controller and its
workspace-copyback processes have stopped...". The cloud harness stop
receipt did not arrive, so the runs stayed in "Recovery needed" for
about an hour, until a person confirmed the stop by hand.

**Expected behavior**
The restarted server proves that the previous controller is gone,
resumes the save, and settles the run with no human step. No user-facing
text asks a person to verify a server or controller.

**Steps to reproduce**
1. Start a native run on a Railway service with a volume.
2. Let the result be accepted and the workspace save start.
3. Deploy, so that the server restarts during the save.
4. See that the run waits for a board confirmation.

**Paperclip version or commit**
Observed on e2d490c61. Reproduced on master before this change. The
owner-proof work in #19848, #19851, #19853, #19868, #19871 and #19883
does not cover this shape: `proveOwnerGone` returns `unprovable`
(`different_host`) for an owner on another container.

## What Changed

- `owner-proof.ts`: `LocalOwnerStamp` can carry the platform `worker`
(deployment identity). `ProcessProbe` can carry this process's
deployment and whether the platform gives it an exclusive volume. New
verdict reason `deployment_replaced`.
- New rule in `proveOwnerGone`, after the current-boot check: the owner
is gone when this server runs with a Railway volume
(`RAILWAY_VOLUME_ID`, `RAILWAY_VOLUME_MOUNT_PATH`) and the stamp names
the same project, environment and service with a different deployment
ID. Railway stops the previous deployment before it mounts the volume
into the new one, and a service with a volume has no replicas. So every
process of the old deployment, including orphaned copyback children, has
exited.
- `native-workspace-finalization-ownership.ts`: evaluate that proof
before the cloud harness request. On `deployment_replaced`, claim the
exact owner receipt under the existing advisory lock and row-lock
revalidation, log `issue.workspace_owner_stop_verified` with
`deploymentProof`, mark the recovery `resuming`, and continue the save
with no provider turn. Other cases keep the harness request and the
existing fences.
- New owner receipts also record the mounted volume ID (`volumeId`).
When a receipt has it, the proof requires this server to mount the same
volume. Both `RAILWAY_VOLUME_ID` and `RAILWAY_VOLUME_MOUNT_PATH` must be
set. The finalizer reaches this check only while it holds the run's
copyback advisory lock, so the old owner's lock session has also ended.
- Copy: the hold's next action, the route conflict and the two Retry
wait messages now describe the wait. They no longer ask a person to
verify a server or controller. The hold's text still names the Resume
saved result action for platforms where neither this rule nor the
harness can confirm the stop.
- `doc/native-workspace-finalization-recovery.md`: describe the rule.

Previous-release story: receipts written by earlier releases already
record `worker.deploymentId`, so they carry forward and take this path
after the upgrade. No schema change.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-workspace-owner-restart-recovery.test.ts
src/services/owner-proof.test.ts`
- Before the fix: "resumes the interrupted save automatically, with no
board action" fails (`acquired: false`).
  - After the fix: all pass.
- The restart test seeds the staging receipt shape (old deployment,
other hostname, PID 3, existing `operator_required` hold). It checks
that the save resumes once, the hold becomes `resuming`, the task
presentation no longer waits for a user, the receipt clears, and no
agent wake is created.
- Negative cases stay fenced: same deployment, a service without a
volume, a half-configured volume, another service, another environment,
and a receipt that names a different volume. Unit tests cover the same
rule in `proveOwnerGone`, including that the current boot stays alive.
- Related suites pass: finalizer, queued-wake recovery, route ownership,
workspace recovery gates and dispatch, explicit continuation, recovery
services (1005 tests).
- `pnpm --filter @paperclipai/server typecheck` passes.

## Risks

- The rule depends on Railway volume exclusivity. It applies only when
this server has a volume mounted and the receipt names the same service
and environment. Without a volume, the existing harness and board paths
still apply.
- A receipt from a deployment that ran before the volume was attached
could, in theory, overlap during that one redeploy. Volumes are attached
at stack creation, so this is not a practical case.
- Copy changes are text only. Error codes are unchanged.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## 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>
2026-11-07 16:12:23 -06:00

295 lines
13 KiB
TypeScript

import { hostname } from "node:os";
import {
currentControllerBootId,
currentControllerProcessStamp,
currentProcessSpaceHash,
observedProcessStartedAt,
processExists,
processStartsDefinitelyDiffer,
PROCESS_START_PRECISION_MS,
UNKNOWN_PROCESS_SPACE,
} from "./controller-process-identity.js";
import { currentWorkspaceWorker, type WorkspaceWorker } from "./native-runtime/native-workspace-platform-proof.js";
/**
* The owner recorded on an in-progress durable record (plan section A).
*
* A local owner is a process in a PID space. A remote owner is a fenced lease:
* ownership moves by fence token and generation, never by a process proof,
* because a remote runner keeps running across a control-plane replacement.
*/
export type OwnerStamp = LocalOwnerStamp | RemoteOwnerStamp;
export type LocalOwnerStamp = {
kind: "local";
/** Record kind or subsystem that wrote the stamp, for reports only. */
scope: string;
generation: number | null;
/** Hostname at stamp time. Informational; `processSpace` is the proof key. */
host: string | null;
/** Hash of host, kernel boot and PID namespace (`currentProcessSpaceHash`). */
processSpace: string | null;
bootId: string | null;
pid: number | null;
processStartedAt: string | null;
processGroupId?: number | null;
/** Start time of the process group's leader (PID = `processGroupId`). When
* the owner leads its own group this is `processStartedAt`. */
processGroupStartedAt?: string | null;
/** The platform deployment that ran the owner, when the platform names one. */
worker?: WorkspaceWorker | null;
/** The platform volume mounted by that deployment, when the stamp recorded one. */
volumeId?: string | null;
/** False when the owner's deployment was not volume-exclusive. Missing on older stamps. */
exclusiveVolume?: boolean | null;
};
export type RemoteOwnerStamp = {
kind: "remote";
scope: string;
generation: number | null;
/** The fence or lease token the owner holds, such as `native_run_finalizations.lease_owner`. */
fenceToken: string | null;
provider?: string | null;
};
export type OwnerVerdict = "gone" | "alive" | "unprovable";
export type OwnerProof = { verdict: OwnerVerdict; reason: OwnerProofReason };
export type OwnerProofReason =
// gone
| "pid_dead"
| "pid_reused"
| "fence_superseded"
| "generation_superseded"
| "provider_stopped"
| "deployment_replaced"
// alive
| "current_process"
| "pid_alive"
| "process_group_alive"
| "fence_held"
| "provider_running"
// unprovable
| "missing_identity"
| "unknown_process_space"
| "different_process_space"
| "different_host"
| "pid_start_unreadable"
| "pid_start_inconsistent"
| "process_group_ambiguous"
| "fence_expired_without_provider_proof";
/** The state a remote owner is judged against, read by the caller from the
* durable record that holds the fence. */
export type RemoteFenceState = {
/** The token that currently holds the fence, or null when released. */
currentFenceToken: string | null;
currentGeneration: number | null;
/** When the current fence expires. Expiry alone never proves an owner gone. */
leaseExpiresAt: Date | null;
/** A provider proof for the stamped lease, when the provider gives one
* (for example a remote termination receipt). */
provider?: "stopped" | "running" | "unknown";
};
/** OS access, injectable so the proof matrix is testable without real PIDs. */
export type ProcessProbe = {
bootId: string;
host: string;
processSpace: string;
pidExists(pid: number): boolean;
processGroupExists(processGroupId: number): boolean;
readStartedAt(pid: number): Promise<Date | null>;
/** This process's platform deployment. `exclusiveVolume` is true only when
* the platform stops every earlier deployment of the service before it
* starts this one (a Railway service with an attached volume). */
deployment?: { worker: WorkspaceWorker; exclusiveVolume: boolean; volumeId?: string | null } | null;
};
function processGroupExists(processGroupId: number): boolean {
if (process.platform === "win32") return false;
try {
process.kill(-processGroupId, 0);
return true;
} catch (error) {
// EPERM: the group has members owned by another user. Alive.
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
}
}
/** Railway mounts a volume into one deployment of a service at a time: it stops
* the previous deployment before the new one starts, so the two never overlap.
* A service with a volume cannot have replicas. */
export function currentDeploymentIdentity(env: NodeJS.ProcessEnv = process.env): ProcessProbe["deployment"] {
const worker = currentWorkspaceWorker(env);
if (!worker) return null;
const volumeId = env.RAILWAY_VOLUME_ID?.trim() || null;
return { worker, exclusiveVolume: Boolean(volumeId && env.RAILWAY_VOLUME_MOUNT_PATH?.trim()), volumeId };
}
export function currentProcessProbe(): ProcessProbe {
return {
deployment: currentDeploymentIdentity(),
bootId: currentControllerBootId(),
host: hostname(),
processSpace: currentProcessSpaceHash(),
pidExists: processExists,
processGroupExists,
readStartedAt: async pid => {
const startedAt = await observedProcessStartedAt(pid);
return startedAt === null ? null : new Date(startedAt);
},
};
}
/** The stamp this process writes when it takes ownership of a record. It does
* no I/O: the start time comes from an earlier resolved identity read, and is
* null until one has happened. A stamp without a start time still proves exit
* when its PID is gone; a live PID then stays unprovable, which fails closed.
*
* It records no process group: the control-plane server owns the record, and
* its provider children are tracked by their own records (the run's process
* identity, the runner's startup attempt). A caller that stamps a child it
* leads must set `processGroupId` and `processGroupStartedAt` itself; without
* them a surviving group is never judged at all. */
export function currentLocalOwnerStamp(scope: string, generation: number | null = null): LocalOwnerStamp {
const controller = currentControllerProcessStamp();
return {
kind: "local", scope, generation, host: hostname(), processSpace: controller.hostHash,
bootId: controller.bootId, pid: controller.pid, processStartedAt: controller.processStartedAt,
processGroupId: null,
};
}
const validPid = (pid: number | null | undefined): pid is number =>
typeof pid === "number" && Number.isInteger(pid) && pid > 0;
/**
* Proves whether the owner in a stamp is gone. Proof is by identity, never by
* age. `unprovable` must never release a record.
*
* Local rules (one prover for every kind):
* - this process's boot id is alive;
* - the stamp must name this PID space (host, kernel boot, PID namespace); a
* different host or namespace is never proof;
* - the PID must be dead, or reused: a live PID that started at least 1 s
* after the stamp (`ps lstart` has 1 s precision). An earlier start is
* inconsistent and unprovable. EPERM is alive;
* - the recorded process group must be empty, or led by a process that
* started definitely after the recorded group leader. A non-empty group without a
* recorded leader start time is unprovable: it may hold surviving children.
*
* Remote rules: a superseded fence token or generation, or a provider proof
* that the stamped lease stopped, is gone. A matching live fence is alive. A
* matching fence that only expired, with no provider proof, is unprovable.
*/
export async function proveOwnerGone(
stamp: LocalOwnerStamp,
probe?: ProcessProbe,
): Promise<OwnerProof>;
export async function proveOwnerGone(
stamp: RemoteOwnerStamp,
fence: RemoteFenceState,
now?: Date,
): Promise<OwnerProof>;
export async function proveOwnerGone(
stamp: OwnerStamp,
context?: ProcessProbe | RemoteFenceState,
now: Date = new Date(),
): Promise<OwnerProof> {
if (stamp.kind === "remote") return proveRemoteOwnerGone(stamp, context as RemoteFenceState, now);
return proveLocalOwnerGone(stamp, (context as ProcessProbe | undefined) ?? currentProcessProbe());
}
async function proveLocalOwnerGone(stamp: LocalOwnerStamp, probe: ProcessProbe): Promise<OwnerProof> {
if (stamp.bootId && stamp.bootId === probe.bootId) return { verdict: "alive", reason: "current_process" };
// The platform stopped the owner's whole deployment, with every process and
// orphaned child in it, before it started this one. Host and PID are moot.
if (deploymentReplaced(stamp, probe.deployment)) return { verdict: "gone", reason: "deployment_replaced" };
if (!validPid(stamp.pid) || !stamp.processSpace) return { verdict: "unprovable", reason: "missing_identity" };
if (stamp.processSpace === UNKNOWN_PROCESS_SPACE || probe.processSpace === UNKNOWN_PROCESS_SPACE) {
return { verdict: "unprovable", reason: "unknown_process_space" };
}
if (stamp.host && stamp.host !== probe.host) return { verdict: "unprovable", reason: "different_host" };
if (stamp.processSpace !== probe.processSpace) return { verdict: "unprovable", reason: "different_process_space" };
let processReason: "pid_dead" | "pid_reused";
if (!probe.pidExists(stamp.pid)) {
processReason = "pid_dead";
} else {
const observed = await probe.readStartedAt(stamp.pid);
if (!stamp.processStartedAt || observed === null) {
return { verdict: "unprovable", reason: "pid_start_unreadable" };
}
const order = compareStarts(stamp.processStartedAt, observed);
if (order === "same") return { verdict: "alive", reason: "pid_alive" };
// A reused PID belongs to a later process. An earlier start means the
// stamp is inconsistent (clock skew or a migrated value): never proof.
if (order !== "later") return { verdict: "unprovable", reason: "pid_start_inconsistent" };
processReason = "pid_reused";
}
const group = stamp.processGroupId;
if (validPid(group) && probe.processGroupExists(group)) {
// A non-empty group can hold the owner's surviving children, including
// detached ones started after the owner. The kernel never reuses a PID
// while a group with that id has members, so the group is a different one
// only when its leader is alive and is definitely not the recorded leader.
if (!probe.pidExists(group)) return { verdict: "alive", reason: "process_group_alive" };
const recordedLeaderStart = group === stamp.pid ? stamp.processStartedAt : stamp.processGroupStartedAt ?? null;
const leaderStartedAt = await probe.readStartedAt(group);
if (!recordedLeaderStart || leaderStartedAt === null) {
return { verdict: "unprovable", reason: "process_group_ambiguous" };
}
const order = compareStarts(recordedLeaderStart, leaderStartedAt);
if (order === "same") return { verdict: "alive", reason: "process_group_alive" };
if (order !== "later") return { verdict: "unprovable", reason: "process_group_ambiguous" };
}
return { verdict: "gone", reason: processReason };
}
function deploymentReplaced(stamp: LocalOwnerStamp, current: ProcessProbe["deployment"]): boolean {
const stamped = stamp.worker;
if (!stamped || !current?.exclusiveVolume) return false;
// A stamp that recorded its volume must name this same volume. Older stamps
// predate the field; the service and environment then name the volume.
if (stamp.exclusiveVolume === false) return false;
if (stamp.volumeId != null && stamp.volumeId !== current.volumeId) return false;
const worker = current.worker;
return stamped.provider === worker.provider && stamped.projectId === worker.projectId
&& stamped.environmentId === worker.environmentId && stamped.serviceId === worker.serviceId
&& typeof stamped.deploymentId === "string" && stamped.deploymentId.length > 0
&& stamped.deploymentId !== worker.deploymentId;
}
/** "same" within `ps lstart` precision (1 s), "later" when the observed process
* started definitely after the recorded one, "earlier" when definitely before,
* "unknown" when either time is unreadable. */
function compareStarts(recorded: string, observed: Date): "same" | "later" | "earlier" | "unknown" {
const left = Date.parse(recorded);
const right = observed.getTime();
if (!Number.isFinite(left) || !Number.isFinite(right)) return "unknown";
if (!processStartsDefinitelyDiffer(recorded, observed)) return "same";
return right - left >= PROCESS_START_PRECISION_MS ? "later" : "earlier";
}
function proveRemoteOwnerGone(stamp: RemoteOwnerStamp, fence: RemoteFenceState, now: Date): OwnerProof {
// The identity check comes first: a provider proof for the current lease
// cannot be tied to a stamp that names no token.
if (!stamp.fenceToken) return { verdict: "unprovable", reason: "missing_identity" };
if (fence.provider === "stopped") return { verdict: "gone", reason: "provider_stopped" };
if (stamp.generation !== null && fence.currentGeneration !== null && fence.currentGeneration > stamp.generation) {
return { verdict: "gone", reason: "generation_superseded" };
}
if (fence.currentFenceToken !== stamp.fenceToken) {
// Released (null) or handed to another owner: the stamped owner no longer
// holds authority, whether or not its process still runs.
return { verdict: "gone", reason: "fence_superseded" };
}
if (fence.provider === "running") return { verdict: "alive", reason: "provider_running" };
if (fence.leaseExpiresAt && fence.leaseExpiresAt > now) return { verdict: "alive", reason: "fence_held" };
return { verdict: "unprovable", reason: "fence_expired_without_provider_proof" };
}