mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native runs on Daytona start a `paperclip-runnerd` process in a sandbox. A graceful shutdown (every rolling deploy) suspends live native runs "without cancelling provider work". The next boot then decides how each run continues. > - #19583, #19616 and #19684 let restart recovery start a remote bootstrap again when durable evidence proves that no provider work exists. > - A chaos replay found another window. The drain suspended a remote run just after its sandbox runner opened the provider session, before the controller sent a turn and before a session snapshot existed. > - Recovery accepted only an unfinished `session.open` in the controller checkpoint, so it blocked the task as ambiguous. A Retry recovered it, but automatic continuation was lost. > - A related replay showed that the same-run bootstrap reuses the sandbox. The interrupted runner can still hold the runner ingress port there (`runner_ingress_bind_conflict`). > - This pull request proves the "session opened, no turn" case and stops the interrupted runner before relaunch. > - The benefit is that a deploy during Daytona startup continues automatically, without a port conflict. ## Linked Issues or Issue Description **What happened?** A chaos replay (ACPX Claude on Daytona) sent SIGTERM while a remote run was starting. The drain recorded `restart_suspended`. The dying controller still launched the runner and finished `session.open`. On the next boot, restart recovery returned `ambiguous_remote_recovery_evidence` with disposition `blocked`. The decrypted controller checkpoint of that run showed: - `run.prepare` completed and `session.open` completed (provider session opened). - Committed events `session.started`, `session.capabilities.updated` and `session.goal.snapshot` (`ackedSourceSeq: 3`). - No turn command. No run-profile session snapshot and no projected provider events. On the next run in that replay, a same-run bootstrap relaunched the runner in the same sandbox (one lease for all attempts). It failed with `runner_ingress_bind_conflict: failed to bind listener: Address already in use`, because the interrupted runner from 51 s earlier was still listening there. The bounded same-run retry recovered it on its last attempt. **Expected behavior** When the durable controller record proves that no turn was ever sent, recovery starts the bootstrap again. It first stops the interrupted runner in the reused sandbox. **Steps to reproduce** 1. Start a native run on a Daytona environment. 2. Send SIGTERM to the control plane while the runner opens its provider session. 3. Restart the control plane after the controller lease expires. 4. See that restart recovery blocks the task with `ambiguous_remote_recovery_evidence`. Root cause: in `server/src/services/native-runtime/native-restart-recovery.ts`, the remote checkpoint proof (`remoteBootstrapProvenIncomplete`, #19583) required an unfinished `session.open` with no committed events, and no provider events at all. A completed `session.open` with only session-open events had no rule, so it fell through to `ambiguous_remote_recovery_evidence`. Refs #19583, #19616, #19684, #19524 ## What Changed - `native-bootstrap-proof.ts`: new `runnerdStateHasNoProviderTurn` proof. The state must hold only `run.prepare` and `session.open` (pending or completed) and no warm, staged or adopted state. Every committed event must be a session-open event, with contiguous sequence numbers from 1, so no event is hidden. Only the controller issues turn commands, and it writes each one to Postgres before sending it. - `native-runner-checkpoint.ts`: `nativeRunnerCheckpointRemoteBootstrapProof` replaces `nativeRunnerCheckpointProvesIncompleteBootstrap`. It also reads a bounded number of `event:N` parts and returns which proof holds. It still rebuilds the state in memory. - `native-restart-recovery.ts`: a remote run can now claim `bootstrap_incomplete` with reason `remote_session_opened_without_turn`. Projected provider events may only be session-open events (`harness.ready`, `session.started`, `session.resumed`, `session.updated`), and only together with the stronger session-opened proof. A turn or provider event still blocks. - `native-session-executor.ts`: - For a remote sandbox target, the executor accepts the same proof on the restored record. - Before the same-run bootstrap relaunches, `stopStaleRemoteRunnerBeforeBootstrap` stops the interrupted runner in the reused sandbox. It uses the verified identity marker and process check from reattachment, which signals only a process whose identity matches this run: SIGTERM, a wait of up to 10 s, then SIGKILL. - The stop is best effort. If no prior runner can be verified, nothing is stopped, and the bounded same-run retry still handles any conflict. - `doc/run-log-events.md`: document the reason and the stop. ## Verification - Red, then green: `cd server && npx vitest run src/services/native-runtime/native-runner-restart-recovery.integration.test.ts -t "drained remote"`. Before the fix, the opened-session case (with a projected `session.started`) returned `blocked` / `ambiguous_remote_recovery_evidence`. - These keep the hold: a `turn.start` command, a committed `turn.started`, an acknowledged event that is missing from the record, and a projected `turn.started`. The executor test restores the checkpoint and checks that the local proof is false and the remote proof is true. - Regression: `native-runner-restart-recovery.integration`, `native-session-executor`, `native-restart-recovery`, `native-bootstrap-retry.integration` and `heartbeat/recovery`: 1460 passed, 14 skipped. - Stale runner stop: controlled sandbox-runner tests (`native-session-executor.test.ts`, "stale runner stop before a same-run remote bootstrap") run the real reattachment identity and process scripts against a fake sandbox runner: - a verified runner that exits on SIGTERM gets SIGTERM only; - a runner that ignores SIGTERM gets SIGTERM, then SIGKILL; - a runner with another identity gets no signal; - no live prior runner means no signal. - Live Daytona proof (wave `w-20261107T021837-s29`, ACPX Claude, `--faults K2 --triggers time,result`, 4 tasks, concurrency 1). The image was built from current master (`paperclip-daytona-runner@sha256:71f711ab…`), and its content id matches this branch. - All 4 tasks crossed a graceful restart, and all passed R1. Two of the K2s fired on the `result` trigger, during a running remote turn. - The K2 during a turn reattached to the live remote runner (`reattach_remote_runner`, generation 2), as designed. - Task t01 failed R6 with an orphan Daytona sandbox that no lease references. The graceful restart came during sandbox creation. This is a separate signature, outside this change. - The timed faults did not land in the exact "session opened, no turn" window. The integration tests cover that window with the evidence from the saved replay database. ## Risks - The proof relies on two facts: only the controller issues turn commands, and the controller writes each command before sending it (`#sendJson` waits for the checkpoint flush). A turn always leaves a `turn.start` command in the record. - The provider session opened in the old sandbox runner is not resumed. The new bootstrap opens a fresh session. No turn ran in the old one, so no work is lost. - The runner stop sends signals inside the run's own sandbox. It signals only after the existing reattachment identity and process checks pass. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, GitHub CLI) and extended reasoning. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
213 lines
11 KiB
TypeScript
213 lines
11 KiB
TypeScript
/**
|
|
* Pure proofs over durable runner state that a bootstrap never reached
|
|
* provider work. Restart recovery and the executor share them, so the claim
|
|
* and the executor's own check accept exactly the same shapes.
|
|
*/
|
|
|
|
export const RUNNERD_CONTROL_PLANE_STATE_SCHEMAS: ReadonlySet<string> = new Set([
|
|
"paperclip.runner.durable.control-plane-state.v1",
|
|
"paperclip.runner.durable.control-plane-state.v2",
|
|
]);
|
|
|
|
const RUNNERD_BOOTSTRAP_COMMAND_TYPES = new Set(["run.prepare", "session.open"]);
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
/** The runner never connected, so every bootstrap command is still undelivered. */
|
|
export function runnerdStateIsUnconnectedBootstrap(state: Record<string, unknown>): boolean {
|
|
const commands = Array.isArray(state.commands) ? state.commands.map(record) : [];
|
|
const committedEvents = Array.isArray(state.committedEvents) ? state.committedEvents : [];
|
|
return RUNNERD_CONTROL_PLANE_STATE_SCHEMAS.has(String(state.schema)) &&
|
|
state.connectionCount === 0 && committedEvents.length === 0 &&
|
|
commands.every(command => command.status === "pending" &&
|
|
RUNNERD_BOOTSTRAP_COMMAND_TYPES.has(String(command.type)));
|
|
}
|
|
|
|
/** Events a runner commits while opening a provider session, before any turn. */
|
|
const SESSION_OPEN_EVENT_TYPES = new Set([
|
|
"harness.ready",
|
|
"session.started",
|
|
"session.resumed",
|
|
"session.updated",
|
|
"session.capabilities.updated",
|
|
"session.goal.snapshot",
|
|
]);
|
|
|
|
/**
|
|
* A remote controller record whose runner opened its provider session but was
|
|
* never sent a turn. Only the controller issues turn commands, and it writes
|
|
* each one to this record before sending it, so a record holding just
|
|
* run.prepare and session.open, with every committed event a session-open
|
|
* event and every committed event accounted for, proves no provider turn,
|
|
* tool call or result exists. Use only where the controller record is the
|
|
* complete authority (a remote runner after its controller's lease expired).
|
|
*/
|
|
export function runnerdStateHasNoProviderTurn(state: Record<string, unknown>): boolean {
|
|
if (runnerdStateIsIncompleteBootstrap(state)) return true;
|
|
if (!RUNNERD_CONTROL_PLANE_STATE_SCHEMAS.has(String(state.schema))) return false;
|
|
if (typeof state.connectionCount !== "number" || !Number.isSafeInteger(state.connectionCount) ||
|
|
state.connectionCount < 0) return false;
|
|
if (state.warmTransition != null || state.completedWarmTransition != null ||
|
|
state.stagedRunAttachment != null) return false;
|
|
if (record(state.bootstrapHistory).adopted === true) return false;
|
|
if (!Array.isArray(state.commands) || !Array.isArray(state.committedEvents)) return false;
|
|
const commands = state.commands.map(record);
|
|
if (!commands.every(command => RUNNERD_BOOTSTRAP_COMMAND_TYPES.has(String(command.type)) &&
|
|
(command.status === "pending" || command.status === "completed"))) return false;
|
|
const events = state.committedEvents.map(record);
|
|
// Every event since the first must still be here, in order, so none is hidden.
|
|
if (typeof state.ackedSourceSeq !== "number" || !Number.isSafeInteger(state.ackedSourceSeq) ||
|
|
state.ackedSourceSeq < 0 || state.ackedSourceSeq > events.length) return false;
|
|
return events.every((event, index) => event.sourceSeq === index + 1 &&
|
|
SESSION_OPEN_EVENT_TYPES.has(String(event.eventType)));
|
|
}
|
|
|
|
/**
|
|
* A provider turn can start only after session.open completes and the
|
|
* controller issues a turn command. A root whose controller journal stops
|
|
* before that point (run.prepare may be acknowledged, session.open never is,
|
|
* no event was committed or acknowledged) and whose runner journal emitted no
|
|
* event either holds no provider work, so restart recovery may quarantine it
|
|
* and bootstrap again. Any other shape stays fail-closed.
|
|
*
|
|
* `runnerJournal` is `undefined` when the runner journal is not on this host
|
|
* (never written locally, or a remote runner), `null` when it is unreadable.
|
|
*/
|
|
export function runnerdStateIsIncompleteBootstrap(
|
|
state: Record<string, unknown>,
|
|
runnerJournal?: Record<string, unknown> | null,
|
|
): boolean {
|
|
if (runnerdStateIsUnconnectedBootstrap(state)) return true;
|
|
if (!RUNNERD_CONTROL_PLANE_STATE_SCHEMAS.has(String(state.schema))) return false;
|
|
if (typeof state.connectionCount !== "number" || !Number.isSafeInteger(state.connectionCount) ||
|
|
state.connectionCount < 0) return false;
|
|
if (!Array.isArray(state.committedEvents) || state.committedEvents.length !== 0) return false;
|
|
if (state.ackedSourceSeq !== 0) return false;
|
|
if (state.warmTransition != null || state.completedWarmTransition != null ||
|
|
state.stagedRunAttachment != null) return false;
|
|
if (record(state.bootstrapHistory).adopted === true) return false;
|
|
if (!Array.isArray(state.commands)) return false;
|
|
const commands = state.commands.map(record);
|
|
if (!commands.every(command => preOpenCommandIsSafe(command.type, command.status))) return false;
|
|
// The controller cannot see runner events that were emitted but never
|
|
// committed. When the runner journal exists it must agree that none were.
|
|
if (runnerJournal === undefined) return true;
|
|
if (runnerJournal === null) return false;
|
|
const processed = Object.values(record(runnerJournal.processedCommands)).map(record);
|
|
return Array.isArray(runnerJournal.outbox) && runnerJournal.outbox.length === 0 &&
|
|
runnerJournal.nextSourceSeq === 1 && runnerJournal.ackedSourceSeq === 0 &&
|
|
processed.every(command => preOpenCommandIsSafe(command.commandType, command.status));
|
|
}
|
|
|
|
/** Runner process lifecycle commands a graceful drain sends; none reaches the provider. */
|
|
const RUNNER_LIFECYCLE_COMMAND_TYPES = new Set(["runner.drain", "runner.suspend", "runner.shutdown"]);
|
|
|
|
/**
|
|
* Before any provider session exists: run.prepare may have completed,
|
|
* session.open may be pending or have failed (no session was handed back),
|
|
* and a drain may have sent runner lifecycle commands in any state.
|
|
*/
|
|
function preOpenCommandIsSafe(type: unknown, status: unknown): boolean {
|
|
if (type === "run.prepare") return status === "pending" || status === "completed";
|
|
if (type === "session.open") return status === "pending" || status === "failed";
|
|
return RUNNER_LIFECYCLE_COMMAND_TYPES.has(String(type));
|
|
}
|
|
|
|
/** Commands that manage the runner process or read session state; none sends provider input. */
|
|
const RUNNER_MAINTENANCE_COMMAND_TYPES = new Set([
|
|
"runner.drain", "runner.suspend", "runner.shutdown", "session.snapshot", "session.goal.get",
|
|
]);
|
|
|
|
/** Events a runner emits while opening a fresh session or managing its own process, before any turn. */
|
|
const PRE_TURN_EVENT_TYPES = new Set([
|
|
"session.starting", "session.started", "session.capabilities.updated", "session.goal.snapshot",
|
|
"harness.starting", "harness.ready", "harness.diagnostic",
|
|
"runner.connected", "runner.disconnected", "runner.reconnected", "runner.diagnostic", "runner.backpressure",
|
|
"runner.drain", "runner.draining", "runner.suspending", "runner.suspended", "runner.shutdown", "runner.stopped",
|
|
]);
|
|
|
|
/** Collects every recorded process and process-group id so each can be proven gone. */
|
|
function collectRecordedProcessIds(value: unknown, into: Array<{ id: unknown; group: boolean }>, depth = 0): boolean {
|
|
if (depth > 10) return false;
|
|
if (Array.isArray(value)) return value.every(entry => collectRecordedProcessIds(entry, into, depth + 1));
|
|
if (!value || typeof value !== "object") return true;
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
// A null id records a launch that had not produced a process yet, so
|
|
// there is nothing to prove gone. Any other non-number fails the proof.
|
|
if (key === "processId" || key === "pid") { if (entry !== null) into.push({ id: entry, group: false }); }
|
|
else if (key === "processGroupId") { if (entry !== null) into.push({ id: entry, group: true }); }
|
|
else if (!collectRecordedProcessIds(entry, into, depth + 1)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function prpEventType(event: Record<string, unknown>): string {
|
|
return String(event.eventType ?? record(record(event.envelope).payload).eventType);
|
|
}
|
|
|
|
export type OpenedSessionProofOptions = {
|
|
/** True only when the runner and its provider processes ran on this host. */
|
|
localProcesses: boolean;
|
|
/** Whether a recorded process (or process-group) id has definitely exited. */
|
|
processGone: (id: unknown, group: boolean) => boolean;
|
|
};
|
|
|
|
/**
|
|
* A drain or restart can land after session.open started a fresh provider
|
|
* session but before the controller issued any turn. That root holds an
|
|
* empty provider session and no provider work, so recovery may quarantine it
|
|
* and bootstrap again when all of these hold:
|
|
*
|
|
* - controller commands are run.prepare and session.open (both completed, the
|
|
* session freshly started rather than a resumed conversation) plus runner
|
|
* maintenance only;
|
|
* - every committed event and every event left in the runner outbox is
|
|
* session setup or runner lifecycle, and compaction left no gap;
|
|
* - the runner journal is present and processed no other command; and
|
|
* - the session's provider process and every other recorded process id has
|
|
* exited on this host.
|
|
*
|
|
* Remote runners stay fail-closed here: their process ids belong to another host.
|
|
*/
|
|
export function runnerdStateIsOpenedSessionBeforeTurn(
|
|
state: Record<string, unknown>,
|
|
runnerJournal: Record<string, unknown> | null | undefined,
|
|
options: OpenedSessionProofOptions,
|
|
): boolean {
|
|
if (!options.localProcesses || !runnerJournal) return false;
|
|
if (!RUNNERD_CONTROL_PLANE_STATE_SCHEMAS.has(String(state.schema))) return false;
|
|
if (state.warmTransition != null || state.completedWarmTransition != null ||
|
|
state.stagedRunAttachment != null) return false;
|
|
if (record(state.bootstrapHistory).adopted === true) return false;
|
|
if (!Array.isArray(state.commands) || !Array.isArray(state.committedEvents)) return false;
|
|
const commands = state.commands.map(record);
|
|
const prepares = commands.filter(command => command.type === "run.prepare");
|
|
const opens = commands.filter(command => command.type === "session.open");
|
|
if (prepares.length !== 1 || prepares[0]!.status !== "completed") return false;
|
|
if (opens.length !== 1 || opens[0]!.status !== "completed") return false;
|
|
if (!commands.every(command => RUNNERD_BOOTSTRAP_COMMAND_TYPES.has(String(command.type)) ||
|
|
RUNNER_MAINTENANCE_COMMAND_TYPES.has(String(command.type)))) return false;
|
|
const opened = record(record(opens[0]!.result).result);
|
|
if (opened.status !== "started" || typeof opened.processId !== "number") return false;
|
|
|
|
const committed = state.committedEvents.map(record);
|
|
if (state.ackedSourceSeq !== committed.length ||
|
|
!committed.every((event, index) => event.sourceSeq === index + 1)) return false;
|
|
if (!Array.isArray(runnerJournal.outbox)) return false;
|
|
const outbox = runnerJournal.outbox.map(record);
|
|
if (![...committed, ...outbox].every(event => PRE_TURN_EVENT_TYPES.has(prpEventType(event)))) return false;
|
|
const processed = Object.values(record(runnerJournal.processedCommands)).map(record);
|
|
if (!processed.every(command => RUNNERD_BOOTSTRAP_COMMAND_TYPES.has(String(command.commandType)) ||
|
|
RUNNER_MAINTENANCE_COMMAND_TYPES.has(String(command.commandType)))) return false;
|
|
|
|
const ids: Array<{ id: unknown; group: boolean }> = [{ id: opened.processId, group: false }];
|
|
if (!collectRecordedProcessIds([...committed, ...outbox, ...processed.map(command => command.result)], ids)) {
|
|
return false;
|
|
}
|
|
return ids.every(({ id, group }) => options.processGone(id, group));
|
|
}
|