mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The web server owns request handling and remote execution coordination. > - Deployment must start a ready replacement while the old server still serves. > - An outage test does not prove that two live servers can overlap safely. > - This pull request adds a separate Product E2E cell for that boundary. > - The existing run, file, recovery, and retained-session checks still apply. ## Linked Issues or Issue Description Refs #18518. The runtime prerequisite is merged; this PR qualifies its ephemeral server and deployment contracts. ## What Changed - Add the explicit `rolling-web-deployment` Codex Daytona workflow. - Start independent A/B/C homes and compiled builds behind a test ingress. - Wait for readiness before routing, then drain and delete the old home. - Observe active authority before handoff and use the existing read-only recovery oracle. - Require ordinary reads and saves to succeed throughout replacement. Read back every accepted save exactly once. - Preserve the five-minute outage suites and their existing validators. - Add a model-free supervisor smoke and an unready replacement control. - Require graceful exit before deleting an old home; forced cleanup cannot satisfy the oracle. - Preserve a durable Stop through lease release/expiry until the recovered execution owner acknowledges it. Permit only fenced reattachment of the original execution for Stop cleanup; submit no new turn. - Exclude events saved by A during shutdown from evidence of B making progress. ## Verification - E2E TypeScript check passed. - Affected harness checks: 143 passed. Catalog, fixture and report checks: 120 passed. - Actual supervisor smoke passed two replacements: 436 reads, 45 saves, zero request failures, and confirmed owned resource cleanup. - B became ready in 18.7 seconds; C became ready in 109.2 seconds. A prior C timeout remains failed evidence. - Fresh browser proof: 11 checks passed, 246 reads, 50 acknowledged saves, zero failures, 1,058ms reconnect, both old homes deleted and cleanup confirmed. - Stop lease-gap regression: three PostgreSQL delivery tests passed with a simulated provider edge. Current server TypeScript build/vendoring passed. - Full harness: 5,814 passed, 17 failed, four skipped. Two shutdown races passed the focused file; two report timeouts passed an isolated 42-test rerun. Onboarding/workflow-security/Pi/date-expired fixtures are unchanged on inspected master; the host embedded PostgreSQL fixture remains a setup limitation. This is not a green full local suite. - CI at `172509b8be` passed all gates. Greptile is 5/5 with every review thread resolved. The earlier access-service race failure passed its isolated regression and the subsequent full CI. - Final review corrections: six focused executor cases, three PostgreSQL command-delivery cases, 19 harness cases and server/E2E typechecks passed. - Three live attempts failed before agent creation: two harness setup gaps were fixed, then provider authentication failed. The saved OpenAI key returns 401; a refresh is pending. The Daytona probe reported cleanup uncertain, and no sandbox with that attempt label was found through the authenticated staging manager. No live model execution is claimed. - Live Runner overlap, released-container qualification and hosted deployment proof remain pending. Merging this opt-in qualification lane does not claim those results. ## Risks - The source lane shares workspace dependencies and does not establish container packaging correctness. - The live workflow needs an explicitly owned S3 bucket, PostgreSQL, pinned remote image and provider credentials. - Qualification cleanup stops all owned processes after failure. A failed replacement must not be reported as a pass. ## Model Used OpenAI Codex, GPT-6. Tool use, code execution and repository review. The runtime does not expose a more specific model ID 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 - [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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
226 lines
16 KiB
TypeScript
226 lines
16 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { canonicalJson } from "../../packages/shared/src/portability-hash.js";
|
|
import { isValidNativePrpEnvelope } from "./native-event-envelope.js";
|
|
import type { StoryCheck, StoryRun } from "./everyday-observations.js";
|
|
import { pollUntil } from "./api.js";
|
|
import { validControllerReplacementReceipt, type ControllerReplacementReceipt } from "./controller-replacement.js";
|
|
import { validControllerRecoveryReceipt } from "./controller-recovery-observer.js";
|
|
|
|
type Row = Record<string, unknown>;
|
|
const record = (value: unknown): Row => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {};
|
|
const id = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
const digest = (value: unknown) => createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
|
|
export interface RemoteRestartEvent {
|
|
seq: number; sourceSeq: number; sourceEventId: string; eventType: string;
|
|
sourceKind: "runner" | "control_plane"; turnId: string | null;
|
|
toolExecutionId: string | null; toolCompleted: boolean; toolFailed: boolean;
|
|
payloadSha256: string; sha256: string;
|
|
}
|
|
export interface RemoteRestartBoundary {
|
|
identity: { runId: string; companyId: string; agentId: string; issueId: string;
|
|
runnerInstanceId: string; nativeSessionId: string; processPid: number | null; processStartedAt: string | null;
|
|
taskIdentitySha256?: string };
|
|
lease: { id: string; environmentId: string; executionWorkspaceId: string | null; providerLeaseSha256: string };
|
|
events: RemoteRestartEvent[];
|
|
}
|
|
export interface RemoteRestartEvidence {
|
|
expectedReplacements?: number;
|
|
replacements?: Array<{ before: RemoteRestartBoundary; after: RemoteRestartBoundary; outage: ControllerReplacementReceipt }>;
|
|
outage?: ControllerReplacementReceipt;
|
|
before?: RemoteRestartBoundary;
|
|
after?: RemoteRestartBoundary;
|
|
final?: { run: StoryRun; events: RemoteRestartEvent[]; operations: Row[];
|
|
scheduledRetry: unknown; recoveryActions: Row[]; observedAt?: string };
|
|
}
|
|
|
|
export async function waitForRemoteRestartBoundary(input: {
|
|
deadlineAt: number;
|
|
load: () => Promise<{ run: StoryRun | undefined; boundary?: RemoteRestartBoundary }>;
|
|
}): Promise<RemoteRestartBoundary> {
|
|
const observed = await pollUntil({
|
|
label: "active remote native turn with completed tool receipt",
|
|
deadlineAt: input.deadlineAt,
|
|
load: input.load,
|
|
accept: state => state.run?.status === "running" && state.boundary !== undefined,
|
|
reject: state => !state.run || state.run.status !== "running"
|
|
? "Remote restart boundary unexercised: original run ended before fault injection" : undefined,
|
|
});
|
|
return observed.boundary!;
|
|
}
|
|
|
|
export function projectRemoteRestartRun(run: StoryRun): StoryRun {
|
|
return { id: run.id, companyId: run.companyId, agentId: run.agentId, nativeIssueId: run.nativeIssueId,
|
|
status: run.status, runtimeMode: run.runtimeMode, runnerInstanceId: run.runnerInstanceId, nativeSessionId: run.nativeSessionId,
|
|
nativePhase: run.nativePhase === "committed" ? "committed" : null,
|
|
resultJson: { finalizationPhase: run.resultJson?.finalizationPhase === "committed" ? "committed" : null,
|
|
workspaceFinalizeStatus: run.resultJson?.workspaceFinalizeStatus === "succeeded" ? "succeeded" : null,
|
|
nextAttemptAt: run.resultJson?.nextAttemptAt == null ? null : "scheduled",
|
|
failureCode: run.resultJson?.failureCode == null ? null : "present" } };
|
|
}
|
|
|
|
/** Keep only receipt identities and hashes; never retain provider text or tool arguments. */
|
|
export function projectRemoteRestartEvents(run: StoryRun, rows: readonly Row[]): RemoteRestartEvent[] {
|
|
const seen = new Set<string>();
|
|
const cursors = new Map<string, number>();
|
|
let lastSeq = 0;
|
|
return rows.filter(row => record(row.payload).prpEvent !== undefined).map(row => {
|
|
const event = record(record(row.payload).prpEvent);
|
|
const source = event.sourceInstanceId;
|
|
const runnerSource = event.sourceKind === "runner" && source === run.runnerInstanceId;
|
|
const controllerSource = event.sourceKind === "control_plane" && source === `${run.runnerInstanceId}:control`
|
|
&& ["run.result.accepted", "run.terminal"].includes(String(event.eventType));
|
|
if (row.companyId !== run.companyId || row.runId !== run.id || !isValidNativePrpEnvelope(event, row.protocolSchemaVersion)
|
|
|| event.runId !== run.id || event.normalizedSessionId !== run.nativeSessionId || event.eventType !== row.eventType
|
|
|| (!runnerSource && !controllerSource) || !id(source) || !id(event.sourceEventId)
|
|
|| !Number.isSafeInteger(row.seq) || Number(row.seq) <= lastSeq || !Number.isSafeInteger(event.sourceSeq)
|
|
|| Number(event.sourceSeq) <= (cursors.get(source) ?? 0) || seen.has(event.sourceEventId)
|
|
|| event.sourceEventId !== `${source}:${run.id}:${event.sourceSeq}`) {
|
|
throw new Error("Remote restart has foreign, duplicate or invalid native receipts");
|
|
}
|
|
seen.add(event.sourceEventId); cursors.set(source, Number(event.sourceSeq)); lastSeq = Number(row.seq);
|
|
const payload = record(event.payload);
|
|
return { seq: lastSeq, sourceSeq: Number(event.sourceSeq), sourceEventId: event.sourceEventId,
|
|
eventType: String(event.eventType), sourceKind: event.sourceKind as RemoteRestartEvent["sourceKind"],
|
|
turnId: id(event.turnId) ? event.turnId : null,
|
|
toolExecutionId: id(payload.executionId) ? payload.executionId : null,
|
|
toolCompleted: event.eventType === "tool.execution.completed" && payload.status === "completed",
|
|
toolFailed: event.eventType === "tool.execution.completed" && payload.status !== "completed",
|
|
payloadSha256: digest(payload),
|
|
sha256: digest(event) };
|
|
});
|
|
}
|
|
|
|
export function observeRemoteRestartBoundary(run: StoryRun, rows: readonly Row[], leases: readonly Row[], scope: {
|
|
companyId: string; agentId: string; issueId: string; environmentId: string;
|
|
}, recovered = false): RemoteRestartBoundary {
|
|
if (run.companyId !== scope.companyId || run.agentId !== scope.agentId || run.nativeIssueId !== scope.issueId
|
|
|| !(run.status === "running" || (recovered && run.status === "succeeded"))
|
|
|| run.runtimeMode !== "native" || !id(run.id) || !id(run.runnerInstanceId) || !id(run.nativeSessionId)) {
|
|
throw new Error("Remote restart boundary requires the original live native process");
|
|
}
|
|
const owned = leases.filter(lease => lease.companyId === scope.companyId && lease.environmentId === scope.environmentId
|
|
&& lease.issueId === scope.issueId && lease.heartbeatRunId === run.id
|
|
&& (lease.provider === "daytona" || lease.provider === "plugin" || String(lease.provider).startsWith("plugin:"))
|
|
&& ((lease.status === "active" && lease.releasedAt == null) || (recovered && run.status === "succeeded"))
|
|
&& id(lease.id) && id(lease.providerLeaseId));
|
|
if (owned.length !== 1) throw new Error("Remote restart boundary requires one exact active sandbox lease");
|
|
const lease = owned[0]!;
|
|
const metadata = record(lease.metadata), task = record(metadata.runnerTaskIdentity);
|
|
const plugin = lease.provider === "plugin" || String(lease.provider).startsWith("plugin:");
|
|
if (plugin) {
|
|
// The normal task authority clears dispatch only after provider status confirms
|
|
// execution stopped. Completion during the outage must retain that proof.
|
|
const settled = recovered && run.status === "succeeded"
|
|
&& metadata.runnerTaskDispatched === false && metadata.runnerTaskExecutionStopped === true;
|
|
if (run.processPid != null || run.processStartedAt != null || (!settled && metadata.runnerTaskDispatched !== true)
|
|
|| task.runnerId !== run.runnerInstanceId || task.runId !== run.id || task.sessionId !== run.nativeSessionId
|
|
|| !id(task.leaseId) || !id(task.turnId) || !id(task.itemId)) {
|
|
throw new Error("Remote restart requires the admitted plugin task identity, not the controller PID");
|
|
}
|
|
} else if (!Number.isSafeInteger(run.processPid) || Number(run.processPid) <= 0
|
|
|| !id(run.processStartedAt) || !Number.isFinite(Date.parse(run.processStartedAt))) {
|
|
throw new Error("Remote restart boundary requires the original live native process");
|
|
}
|
|
const events = projectRemoteRestartEvents(run, rows);
|
|
const starts = events.filter(event => event.eventType === "turn.started" && event.sourceKind === "runner");
|
|
const completed = events.filter(event => event.toolCompleted && event.sourceKind === "runner" && event.toolExecutionId);
|
|
if (starts.length !== 1 || !id(starts[0]!.turnId) || completed.length === 0
|
|
|| completed.some(event => event.turnId !== starts[0]!.turnId)
|
|
|| (!recovered && events.some(event => ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted", "run.terminal"].includes(event.eventType)))) {
|
|
throw new Error("Remote restart boundary has no completed tool in an active original provider turn");
|
|
}
|
|
return {
|
|
identity: { runId: run.id, companyId: scope.companyId, agentId: scope.agentId, issueId: scope.issueId,
|
|
runnerInstanceId: run.runnerInstanceId, nativeSessionId: run.nativeSessionId,
|
|
processPid: plugin ? null : Number(run.processPid), processStartedAt: plugin ? null : run.processStartedAt!,
|
|
...(plugin ? { taskIdentitySha256: digest({ runnerId: task.runnerId, leaseId: task.leaseId,
|
|
runId: task.runId, sessionId: task.sessionId, turnId: task.turnId, itemId: task.itemId }) } : {}) },
|
|
lease: { id: String(lease.id), environmentId: scope.environmentId,
|
|
executionWorkspaceId: typeof lease.executionWorkspaceId === "string" ? lease.executionWorkspaceId : null,
|
|
providerLeaseSha256: digest(lease.providerLeaseId) },
|
|
events,
|
|
};
|
|
}
|
|
|
|
function retainsPrefix(before: readonly RemoteRestartEvent[], after: readonly RemoteRestartEvent[]): boolean {
|
|
return before.length > 0 && after.length >= before.length && before.every((event, index) => digest(event) === digest(after[index]));
|
|
}
|
|
|
|
export function gradeRemoteRestart(evidence: RemoteRestartEvidence | undefined): StoryCheck[] {
|
|
const { before, after, final } = evidence ?? {};
|
|
const check = (id: string, passed: boolean, detail: string) => ({ id, passed, detail });
|
|
const sameIdentity = Boolean(before && after && digest(before.identity) === digest(after.identity)
|
|
&& digest(before.lease) === digest(after.lease) && retainsPrefix(before.events, after.events));
|
|
const run = final?.run;
|
|
const identity = before?.identity;
|
|
const finalIdentity = Boolean(identity && run && run.id === identity.runId && run.companyId === identity.companyId
|
|
&& run.agentId === identity.agentId && run.nativeIssueId === identity.issueId && run.runtimeMode === "native"
|
|
&& run.runnerInstanceId === identity.runnerInstanceId && run.nativeSessionId === identity.nativeSessionId);
|
|
const events = final?.events ?? [];
|
|
const turnId = before?.events.find(event => event.eventType === "turn.started")?.turnId;
|
|
const once = (kind: string, source: RemoteRestartEvent["sourceKind"]) => events.filter(event => event.eventType === kind && event.sourceKind === source).length === 1;
|
|
// Reattachment can redeliver an already-observed activity notification. Its
|
|
// stable identity and exact payload must agree; a new submission, changed
|
|
// result or failed tool is not receipt replay and must never pass this cell.
|
|
const starts = events.filter(event => event.eventType === "turn.started" && event.sourceKind === "runner");
|
|
const tools = events.filter(event => event.toolCompleted);
|
|
const toolResults = new Map<string, string>();
|
|
let consistentToolResults = true;
|
|
for (const event of tools) {
|
|
if (!event.toolExecutionId || (toolResults.has(event.toolExecutionId) && toolResults.get(event.toolExecutionId) !== event.payloadSha256)) consistentToolResults = false;
|
|
else toolResults.set(event.toolExecutionId, event.payloadSha256);
|
|
}
|
|
const nativeCompletion = finalIdentity && Boolean(before && after && retainsPrefix(after.events, events))
|
|
&& once("turn.submitted", "runner") && starts.length > 0 && new Set(starts.map(event => event.payloadSha256)).size === 1
|
|
&& once("turn.completed", "runner")
|
|
&& once("run.result.accepted", "control_plane") && once("run.terminal", "control_plane")
|
|
&& events.filter(event => event.eventType.startsWith("turn.")).every(event => event.turnId === turnId || (event.eventType === "turn.submitted" && event.turnId === null))
|
|
&& consistentToolResults && !events.some(event => event.toolFailed);
|
|
const operations = final?.operations ?? [];
|
|
const saved = finalIdentity && run?.status === "succeeded" && run.nativePhase === "committed"
|
|
&& run.resultJson?.finalizationPhase === "committed" && run.resultJson.workspaceFinalizeStatus === "succeeded"
|
|
&& run.resultJson.nextAttemptAt == null && run.resultJson.failureCode == null
|
|
&& operations.some(operation => operation.companyId === identity?.companyId && operation.heartbeatRunId === identity?.runId
|
|
&& operation.issueId === identity?.issueId && operation.phase === "workspace_finalize" && operation.status === "succeeded"
|
|
&& operation.executionWorkspaceId === before?.lease.executionWorkspaceId)
|
|
&& !operations.some(operation => ["pending", "queued", "running"].includes(String(operation.status)))
|
|
&& final?.scheduledRetry == null && !final?.recoveryActions.some(action => ["active", "pending"].includes(String(action.status)));
|
|
const expectedReplacements = evidence?.expectedReplacements ?? 1;
|
|
const replacements = evidence?.replacements;
|
|
const allReplacements = replacements === undefined ? expectedReplacements === 1
|
|
: replacements.length === expectedReplacements && replacements.every((step, index, all) => {
|
|
const previous = all[index - 1];
|
|
const preservesIdentity = digest(step.before.identity) === digest(step.after.identity)
|
|
&& digest(step.before.lease) === digest(step.after.lease)
|
|
&& retainsPrefix(step.before.events, step.after.events);
|
|
const authenticated = validControllerReplacementReceipt(step.outage) && validControllerRecoveryReceipt(step.outage)
|
|
&& step.outage.recovery?.binding.runId === step.before.identity.runId
|
|
&& step.outage.recovery.binding.companyId === step.before.identity.companyId
|
|
&& step.outage.recovery.binding.issueId === step.before.identity.issueId
|
|
&& step.outage.recovery.runnerInstanceId === step.before.identity.runnerInstanceId
|
|
&& step.outage.recovery.nativeSessionId === step.before.identity.nativeSessionId;
|
|
const artifactsPresent = expectedReplacements <= 1 || Boolean(step.outage.previousArtifact && step.outage.replacementArtifact);
|
|
const chained = !previous || (retainsPrefix(previous.after.events, step.before.events)
|
|
&& (!step.outage.previousArtifact || Boolean(previous.outage.replacementArtifact
|
|
&& digest(previous.outage.replacementArtifact) === digest(step.outage.previousArtifact))));
|
|
return preservesIdentity && authenticated && artifactsPresent && chained;
|
|
});
|
|
return [
|
|
check("remote-restart-same-execution-and-lease", sameIdentity, "The original run, native session, admitted runner identity and sandbox lease survive replacement."),
|
|
check("remote-restart-native-completion-once", nativeCompletion, "The original provider turn completes once and retains its native receipt prefix."),
|
|
check("remote-restart-files-finalized", Boolean(saved), "The original run commits its workspace receipt and leaves no active operation or recovery."),
|
|
check("remote-restart-fault-exercised", validControllerReplacementReceipt(evidence?.outage), "The requested outage or ready-before-handoff replacement occurred, and the departing server state is unavailable."),
|
|
check("remote-restart-authenticated-recovery", validControllerRecoveryReceipt(evidence?.outage)
|
|
&& evidence?.outage?.recovery?.binding.runId === identity?.runId
|
|
&& evidence?.outage?.recovery?.runnerInstanceId === identity?.runnerInstanceId
|
|
&& evidence?.outage?.recovery?.nativeSessionId === identity?.nativeSessionId,
|
|
"Natural ownership acquisition and new authenticated runner receipts were observed within the original 420-second grace."),
|
|
check("remote-restart-backlog-settled", Boolean(saved && nativeCompletion && final?.observedAt
|
|
&& Date.parse(final.observedAt) >= Date.parse(evidence?.outage?.startedAt ?? "") + (evidence?.outage?.recovery?.resumedProcessingObservedAfterMs ?? Infinity)),
|
|
"Canonical terminal and workspace receipts settle the original backlog after reattachment."),
|
|
check("remote-restart-all-replacements", allReplacements,
|
|
"Every requested upgrade and rollback exercised the original running execution and retained its receipts."),
|
|
];
|
|
}
|