Files
PaperClipAI/server/src/services/native-runtime/native-bootstrap-proof.ts
T
DottaandClaude Opus 5.5 4612fa9fb4 fix(native): restart a drained Daytona run whose runner opened its session (#19711)
## 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>
2026-11-06 20:58:32 -06:00

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));
}