mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When a graceful drain suspends a remote native run during its start, restart recovery may bootstrap it again only if the run's Postgres controller checkpoint proves that no provider turn exists > - That proof accepted `run.prepare`, `session.open` and session-open events > - A codex controller reads the session goal (`session.goal.get`, a read-only query) right after it opens the session and before it sends the first turn. A drain that lands in that window leaves a third command in the checkpoint > - The proof refused any checkpoint with a third command. Recovery called it `ambiguous_remote_recovery_evidence` and blocked replay, so the task waited for the board > - This pull request lets the proof accept the runner maintenance and read-only session commands that already count as maintenance in the local proof, and reads every recorded command > - The benefit is that a run drained in the first seconds of a per-turn Daytona start bootstraps again on its own ## Linked Issues or Issue Description Refs #20136 **What happened?** Daytona per-turn restart-chaos wave w-20261108T225520-s9501 (master15b987d9b8), task t06, subtask CHA-13, run `687578c9`: - A K2 graceful drain at 23:11:55.096 suspended the run 4 s into its start. - Restart recovery at 23:12:03 blocked it as `native_restart_recovery_blocked` / `ambiguous_remote_recovery_evidence`, and replay was blocked as `preserve_without_replay_v1`. - The subtask rested `blocked`. Its parent CHA-10 then failed R1. I decrypted the run's controller checkpoint with the kept instance's local key: - **commands:** `run.prepare` (completed), `session.open` (completed), `session.goal.get` (completed, written 18 ms before the drain); - **events:** `session.started`, `session.capabilities.updated`, `session.goal.snapshot` ×2; - **cursor:** `ackedSourceSeq` 4, `commandCount` 3. No turn was ever sent. `nativeRunnerCheckpointRemoteBootstrapProof` rejected the checkpoint because only the parts `command:1` and `command:2` were allowed, and `runnerdStateHasNoProviderTurn` accepted only bootstrap commands. **Expected behavior** A controller record that holds only a completed bootstrap, read-only session commands and session-open events proves that no provider turn exists. The run bootstraps again (`remote_session_opened_without_turn`) without a Retry. **Steps to reproduce** 1. Start a codex run on a remote sandbox and drain the control plane after `session.open` completes and `session.goal.get` is issued, but before `turn.start`. 2. Restart. Before this change, recovery blocks the run with `ambiguous_remote_recovery_evidence`. **Paperclip version or commit**15b987d9b8(wave). Branch rebased on current master. ## What Changed - `native-runner-checkpoint.ts` (`nativeRunnerCheckpointRemoteBootstrapProof`): - accepts up to 8 `command:N` parts and reads them all in order; - requires the stored command parts to match the cursor's `commandCount`, so no command can be missing; - keeps the 16-event bound. - `native-bootstrap-proof.ts` (`runnerdStateHasNoProviderTurn`): besides `run.prepare` and `session.open`, also accepts the existing `RUNNER_MAINTENANCE_COMMAND_TYPES`: - `runner.drain`, `runner.suspend` and `runner.shutdown`; - `session.snapshot` and `session.goal.get`. The local opened-session proof already treats these as maintenance; none of them sends provider input. - `native-runner-restart-recovery.integration.test.ts`: - a red/green test with the exact CHA-13 shape, covering both the recovery claim and the executor's restored-checkpoint proof; - a held case where a `session.goal.set` follows the read. ## Verification - Red/green: on master, the new test is blocked. With the change, recovery claims `bootstrap_incomplete` with the reason `remote_session_opened_without_turn`, and the executor's proof agrees. - Existing held cases still hold: a turn was issued, a turn event was committed, an acknowledged event is missing, a provider turn event was projected, and the new `session.goal.set` case. - `vitest run` over the native bootstrap, restart-recovery and runner-checkpoint test files and `native-session-executor.test.ts`: 9 files, 1542 tests passed (14 skipped). - `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed. ## Risks - Low risk: - The added commands are the same maintenance and read-only set the local proof already accepts. - Any turn command, any non-session-open event, a missing acknowledged event, or a command count that differs from the stored parts still fails closed. - Not changed: when replay was already blocked and the saved work was later recovered, the task still waits for a board Retry. This fix keeps that block from happening for this shape. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## 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>
214 lines
12 KiB
TypeScript
214 lines
12 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 => RUNNER_MAINTENANCE_COMMAND_TYPES.has(String(command.type)) ||
|
|
(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));
|
|
}
|