fix: continue historical local runs from retained stop evidence

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 17:50:25 -05:00
parent 1c4bcff2b1
commit 7c246bb34c
9 changed files with 699 additions and 4 deletions

View File

@ -895,6 +895,16 @@ Native admission verifies local process identities for local runs. Remote runs
instead require a provider termination receipt for every lease, with successful
cleanup and no active ownership. This applies to both per-turn and warm native
runners. A stop receipt retires only the settled cleanup owner for that exact company, run, provider, and sandbox resource, without changing its checkpoint or recorded action outcomes. Independent remote sandboxes have separate cleanup gates, including when one run owns multiple sandboxes. Successful pending-cleanup retries persist the same receipt and reconsider deferred user messages; a delivery failure never reverts successful provider cleanup. A failed checkpoint does not prevent destruction of a terminal run's isolated sandbox; busy ownership still prevents it.
Older local Codex runs may have lost their process IDs before stop receipts were
introduced. A saved post-stop user message may reconcile this legacy case from
the exact retained session: the old controller must be gone, all reconnect
credentials expired, the runner suspended, and the complete startup journal must
identify every provider process in that run. Every recorded process and process
group must be absent, and the retained files must remain unchanged. This path
never launches the old runner or replays its commands. A newer launch record,
incomplete inventory, active process, or unknown state keeps the hold. The queue
keeps the server's wait explanation through normalization and optimistic updates.
Missing receipts and failed cleanup retain the hold. Older providers that return
no receipt remain supported but cannot authorize remote continuation. A terminal
database status or a PID check on the wrong host is insufficient.

View File

@ -3,7 +3,7 @@ import { recordNativeLocalProcessStop, hasNativeLocalProcessStop, PROCESS_START_
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { beforeAll, afterAll, describe, it, expect } from "vitest";
import { beforeAll, afterAll, describe, it, expect, vi } from "vitest";
import {
approvals, issueApprovals, issueThreadInteractions,
agentWakeupRequests, agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issueRecoveryActions,
@ -49,6 +49,33 @@ const support = await getEmbeddedPostgresTestSupport();
agentId: f.agentId, status: "queued", contextSnapshot: { issueId: f.issueId, previousRunId: result.previousRunId, forceFreshSession: true } });
return result;
});
it("reconciles legacy stop evidence transactionally and never uses it for a newer launch", async () => {
const runtime = await import("./native-runtime/native-session-executor.js");
const verify = vi.spyOn(runtime, "verifyRetainedLocalProcessStop").mockReturnValue({
fingerprint: "verified-retained-snapshot", providerProcessIds: [999999998], controllerPid: 999999999,
});
try {
const f = await seed();
await db.update(heartbeatRuns).set({ processPid: null }).where(eq(heartbeatRuns.id, f.sourceRunId));
const [environment] = await db.insert(environments).values({ name: "Local legacy", driver: "process" }).returning();
await db.insert(environmentLeases).values({ companyId: f.companyId, heartbeatRunId: f.sourceRunId,
environmentId: environment.id, provider: "local", status: "failed", leasePolicy: "ephemeral", releasedAt: new Date() });
expect(await admit(f, true)).toMatchObject({ previousRunId: f.sourceRunId });
expect(await hasNativeLocalProcessStop(db, f.companyId, f.sourceRunId)).toBe(false);
expect(await admit(f)).toMatchObject({ previousRunId: f.sourceRunId });
expect(await hasNativeLocalProcessStop(db, f.companyId, f.sourceRunId)).toBe(true);
expect(await admit(f)).toBeNull();
const next = await seed();
await db.update(heartbeatRuns).set({ processPid: null }).where(eq(heartbeatRuns.id, next.sourceRunId));
await appendHeartbeatRunEvent(db, { companyId: next.companyId, runId: next.sourceRunId,
agentId: next.agentId, eventType: PROCESS_START_REQUESTED });
verify.mockClear();
expect(await admit(next)).toBeNull();
expect(verify).not.toHaveBeenCalled();
} finally { verify.mockRestore(); }
});
it("preserves local stop proof after process metadata is cleared and invalidates it on another launch", async () => {
const f = await seed();
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));

View File

@ -1,4 +1,4 @@
import { hasNativeLocalProcessStop } from "./native-local-process-stop.js";
import { hasNativeLocalProcessStop, reconcileLegacyNativeLocalStop } from "./native-local-process-stop.js";
import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js";
import { hasRemoteTerminationReceipt, remoteLeaseCleanupScope } from "./remote-execution-termination.js";
import { z } from "zod";
@ -103,7 +103,8 @@ export async function admitExplicitNativeContinuation(input: {
if (!unusedAdmission) {
// A missing process identity is not evidence that a provider exited.
if (!run.processPid && !run.processGroupId &&
!await hasNativeLocalProcessStop(db, companyId, run.id)) return blocked("process_identity_missing", "The previous run has no verified stop record. Paperclip cannot start this message yet.");
!await hasNativeLocalProcessStop(db, companyId, run.id) &&
!await reconcileLegacyNativeLocalStop(db, run, coordinator, input.dryRun === true)) return blocked("process_identity_missing", "The previous run has no verified stop record. Paperclip cannot start this message yet.");
if (run.processPid && !processStopped(run.processPid)) return blocked("process_running", "Waiting for the previous process to stop. Your message will start automatically.");
if (run.processGroupId && !processStopped(-run.processGroupId)) return blocked("process_running", "Waiting for the previous process to stop. Your message will start automatically.");
}

View File

@ -1,5 +1,5 @@
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, type Db } from "@paperclipai/db";
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
export const PROCESS_START_REQUESTED = "native.process_start_requested";
@ -58,3 +58,34 @@ export async function hasNativeLocalProcessStop(db: Db, companyId: string, runId
.limit(1);
return event?.eventType === LOCAL_PROCESS_STOPPED;
}
/** Upgrade old cleared identities only from an exact, closed retained session.
* New-format launches must use their normal stop receipt, never this fallback.
*/
export async function reconcileLegacyNativeLocalStop(
db: Db, run: typeof heartbeatRuns.$inferSelect,
coordinator: typeof nativeRunFinalizations.$inferSelect | undefined,
dryRun: boolean,
): Promise<boolean> {
if (!coordinator) return false;
const [newFormat] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
isNull(heartbeatRunEvents.sourceEventId),
inArray(heartbeatRunEvents.eventType, [PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED, LOCAL_PROCESS_STOPPED]),
)).limit(1);
if (newFormat) return false;
const leases = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
));
if (!leases.length || leases.some(lease => lease.provider !== "local" || !lease.releasedAt || lease.cleanupStatus === "failed")) return false;
const { verifyRetainedLocalProcessStop } = await import("./native-runtime/native-session-executor.js");
const proof = verifyRetainedLocalProcessStop(run, coordinator);
if (!proof) return false;
if (!dryRun) await appendHeartbeatRunEvent(db, {
companyId: run.companyId, runId: run.id, agentId: run.agentId,
eventType: LOCAL_PROCESS_STOPPED, stream: "system", level: "info",
message: "Verified the stopped local provider from its retained session before continuing the user message.",
payload: { ...proof, source: "retained_local_session_v1" },
});
return true;
}

View File

@ -1,3 +1,4 @@
import { retainedLocalProviderProcesses } from "./retained-local-stop.js";
import { PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js";
@ -1635,6 +1636,55 @@ function cleanupStateSnapshot(root: string) {
};
}
/** Read-only compatibility proof for failed local Codex runs whose old recovery
* cleared process metadata. Caller holds the issue/coordinator locks and checks
* local leases and the absence of newer server launch records. No old command
* is executed, no session is resumed, and no unknown action outcome is changed.
*/
export function verifyRetainedLocalProcessStop(
run: typeof heartbeatRuns.$inferSelect,
coordinator: typeof nativeRunFinalizations.$inferSelect,
): { fingerprint: string; providerProcessIds: number[]; controllerPid: number } | null {
try {
if (run.runtimeMode !== "native" || run.status !== "failed" || !run.finishedAt ||
run.errorCode !== "native_runner_process_exited" || run.processPid || run.processGroupId ||
!run.nativeIssueId || !run.nativeSessionId || !run.runnerInstanceId ||
coordinator.companyId !== run.companyId || coordinator.runId !== run.id ||
coordinator.issueId !== run.nativeIssueId || coordinator.phase !== "terminal_failure" ||
coordinator.leaseOwner || coordinator.resultId || coordinator.nextAttemptAt ||
!Number.isSafeInteger(coordinator.controllerPid) || Number(coordinator.controllerPid) <= 0) return null;
// A stopped control-plane process cannot mint another reconnect ticket.
try { process.kill(coordinator.controllerPid!, 0); return null; }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") return null; }
const execution = parseNativeExecutionInput(record(run.runnerProfileJson).nativeExecutionInput);
if (execution.binding.companyId !== run.companyId || execution.binding.agentId !== run.agentId ||
execution.binding.issueId !== run.nativeIssueId || execution.binding.runId !== run.id ||
nativeSessionKey(execution) !== run.nativeSessionId || execution.provider.kind !== "codex" ||
execution.session.driverKind !== "codex_app_server") return null;
const scope = nativeSessionScopeKey(execution);
if (activeNativeSessions.has(run.id) || executingRunnerdSessionScopes.has(scope) ||
initializingSessionToolAuthorities.has(scope) || warmNativeSessions.has(scope)) return null;
// Never migrate, search other scopes, or activate a retained directory here.
const root = scopedRunnerdStateRoot(execution);
const snapshot = cleanupStateSnapshot(root);
const identity = record(snapshot.control.identity);
if (identity.runnerInstanceId !== run.runnerInstanceId ||
identity.environmentLeaseId !== execution.binding.executionWorkspaceId ||
identity.runId !== run.id || identity.normalizedSessionId !== run.nativeSessionId ||
typeof identity.turnId !== "string" || typeof identity.itemId !== "string") return null;
const processes = retainedLocalProviderProcesses({ snapshot, now: new Date(), identity: {
runnerInstanceId: run.runnerInstanceId, environmentLeaseId: execution.binding.executionWorkspaceId,
runId: run.id, normalizedSessionId: run.nativeSessionId,
turnId: identity.turnId, itemId: identity.itemId,
} });
if (!processes || !processes.every(cleanupProcessAbsent)) return null;
if (cleanupStateSnapshot(root).fingerprint !== snapshot.fingerprint) return null;
return { fingerprint: snapshot.fingerprint, providerProcessIds: processes, controllerPid: coordinator.controllerPid! };
} catch {
return null;
}
}
function cleanupProcessAbsent(pid: unknown): pid is number {
if (
process.platform === "win32" ||

View File

@ -0,0 +1,359 @@
import { describe, expect, it, vi } from "vitest";
import { retainedLocalProviderProcesses } from "./retained-local-stop.js";
import { validatePrpEvent } from "../../vendor/paperclip-runner/index.js";
export function retainedStopFixture() {
const identity = {
runnerInstanceId: "runner-1",
environmentLeaseId: "workspace-1",
runId: "run-1",
normalizedSessionId: "session-1",
turnId: "turn-1",
itemId: "item-1",
};
const events = ["intent", "spawned", "intent", "spawned"].map(
(phase, index) => {
const generation = index < 2 ? 2 : 3;
const event = {
schema: "paperclip.prp.event.v1",
schemaVersion: 1,
eventType: "harness.diagnostic",
sourceKind: "runner",
sourceInstanceId: identity.runnerInstanceId,
sourceEventId: `event-${index + 1}`,
sourceSeq: index + 1,
runId: identity.runId,
normalizedSessionId: identity.normalizedSessionId,
turnId: identity.turnId,
itemId: identity.itemId,
priority: 1,
emittedAt: "2026-09-11T10:00:00.000Z",
payload: {
startup: {
schema: "paperclip.provider_startup.v1",
origin: { ...identity },
launchId: `launch-${generation}`,
attemptedProcessGeneration: generation,
phase,
processId: phase === "spawned" ? 999999990 + generation : null,
processGroupId: phase === "spawned" ? 999999990 + generation : null,
},
},
};
return {
sourceSeq: index + 1,
envelope: { ...identity, payload: event },
};
},
);
const snapshot = {
fingerprint: "fixture",
fileSha256: ["a", "b", "c"] as const,
control: {
schema: "paperclip.runner.durable.control-plane-state.v1",
identity: { ...identity },
tickets: { ticket: { expiresAt: "2026-09-11T10:01:00Z" } },
leases: { lease: { expiresAt: "2026-09-11T11:00:00Z" } },
commands: [
{ type: "session.snapshot", status: "pending", controllerSeq: 14 },
],
committedEvents: events,
ackedSourceSeq: events.length,
},
runner: {
schema: "paperclip.runner.durable.state.v1",
...identity,
lifecycle: "suspended",
pendingTerminalDelivery: null as unknown,
pendingProviderCleanup: null as unknown,
outbox: [] as unknown[],
ackedSourceSeq: events.length,
nextSourceSeq: events.length + 1,
lastControllerCommandSeq: 13,
},
provider: {
schema: "paperclip.runner.codex-provider-state.v1",
lifecycle: "prepared",
startupAttempt: null as unknown,
providerProcessGeneration: 3,
pendingEvents: [] as unknown[],
queuedEvents: [] as unknown[],
toolBridge: { pending: {} },
activeProviderTurnId: null as string | null,
ambiguousTurnStartPending: false,
},
};
return { identity, snapshot, now: new Date("2026-09-11T12:00:00Z") };
}
describe("retained local provider inventory", () => {
it("recovers every recorded launch from the complete suspended journal", () => {
const f = retainedStopFixture();
for (const entry of f.snapshot.control.committedEvents)
expect(
validatePrpEvent(entry.envelope.payload),
JSON.stringify(validatePrpEvent(entry.envelope.payload)),
).toMatchObject({ ok: true });
expect(retainedLocalProviderProcesses(f)).toEqual([999999992, 999999993]);
});
it.each([
"foreign_scope",
"foreign_event",
"foreign_launch",
"live_runner",
"pending_delivery",
"outbox",
"live_provider",
"startup_intent",
"active_turn",
"ambiguous_turn",
"ticket",
"lease",
"missing_event",
"new_generation",
"missing_spawn",
"bad_pid",
"wrong_group",
"pending_launch",
"bad_schema",
"missing_credentials",
"duplicate_sequence",
"provider_cleanup",
"uncommitted_provider_event",
])("rejects incomplete or unsettled evidence: %s", (kind) => {
const f = retainedStopFixture();
const { control, runner, provider } = f.snapshot;
const event = control.committedEvents[0]!.envelope.payload;
const spawned =
control.committedEvents[3]!.envelope.payload.payload.startup;
switch (kind) {
case "uncommitted_provider_event":
provider.pendingEvents.push({ eventType: "harness.diagnostic" });
break;
case "foreign_scope":
control.identity.runId = "other";
break;
case "foreign_event":
event.sourceInstanceId = "other";
break;
case "foreign_launch":
event.payload.startup.origin.runId = "other";
break;
case "live_runner":
runner.lifecycle = "ready";
break;
case "pending_delivery":
runner.pendingTerminalDelivery = {};
break;
case "provider_cleanup":
runner.pendingProviderCleanup = {};
break;
case "outbox":
runner.outbox.push({});
break;
case "live_provider":
provider.lifecycle = "turn_active";
break;
case "startup_intent":
provider.startupAttempt = {};
break;
case "active_turn":
provider.activeProviderTurnId = "turn";
break;
case "ambiguous_turn":
provider.ambiguousTurnStartPending = true;
break;
case "ticket":
control.tickets.ticket.expiresAt = "2026-09-12T00:00:00Z";
break;
case "lease":
control.leases.lease.expiresAt = "invalid";
break;
case "missing_credentials":
control.leases = {} as typeof control.leases;
break;
case "missing_event":
control.committedEvents.shift();
break;
case "new_generation":
provider.providerProcessGeneration++;
break;
case "missing_spawn":
spawned.phase = "intent";
break;
case "bad_pid":
spawned.processId = -1;
break;
case "wrong_group":
spawned.processGroupId = 42;
break;
case "pending_launch":
control.commands[0]!.type = "run.attach";
break;
case "bad_schema":
runner.schema = "unknown";
break;
case "duplicate_sequence":
event.sourceSeq = 2;
break;
}
expect(retainedLocalProviderProcesses(f)).toBeNull();
});
});
describe("retained local stop host verification", () => {
it.each([
"stopped",
"live_controller",
"live_provider",
"permission_denied",
"foreign_company",
"newer_lease",
"symlink",
"missing_file",
])(
"checks persisted scope and real host evidence: %s",
async (kind) => {
const { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } =
await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { createHash } = await import("node:crypto");
const { canonicalNativeJson } = await import("./canonical.js");
const { verifyRetainedLocalProcessStop } =
await import("./native-session-executor.js");
const f = retainedStopFixture();
const base = mkdtempSync(join(tmpdir(), "retained-stop-"));
const previousBase = process.env.PAPERCLIP_RUNNER_STATE_DIR;
process.env.PAPERCLIP_RUNNER_STATE_DIR = base;
const execution = {
schema: "paperclip.native-execution-input.v1",
provider: { kind: "codex", model: null },
binding: {
companyId: "company",
agentId: "agent",
issueId: "issue",
runId: "run-1",
executionWorkspaceId: "workspace-1",
},
task: {
identifier: "TEST-1",
title: "Continue",
description: null,
prompt: "Continue",
workMode: "standard",
},
workspace: {
cwd: base,
repoUrl: null,
repoRef: null,
branchName: null,
},
session: {
normalizedSessionId: "session-1",
driverKind: "codex_app_server",
protocolVersion: 1,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
},
completionContract: {
id: "contract",
sha256: "sha",
schemaVersion: "paperclip.completion-contract.v1",
contract: {
revision: "1",
objective: "Continue",
criteria: [{ id: "objective", requirement: "Finish" }],
},
},
interactionResponses: [],
credentialBindings: [],
};
const run = {
id: "run-1",
companyId: "company",
agentId: "agent",
nativeIssueId: "issue",
runtimeMode: "native",
status: "failed",
finishedAt: new Date("2026-09-11T11:00:00Z"),
errorCode: "native_runner_process_exited",
processPid: null,
processGroupId: null,
nativeSessionId: "session-1",
runnerInstanceId: "runner-1",
runnerProfileJson: { nativeExecutionInput: execution },
} as unknown as Parameters<typeof verifyRetainedLocalProcessStop>[0];
const coordinator = {
companyId: "company",
issueId: "issue",
runId: "run-1",
phase: "terminal_failure",
controllerPid: kind === "live_controller" ? process.pid : 999999999,
} as Parameters<typeof verifyRetainedLocalProcessStop>[1];
const scope = canonicalNativeJson({
schema: "paperclip.native-session-scope.v2",
companyId: "company",
agentId: "agent",
workspace: { kind: "managed", executionWorkspaceId: "workspace-1" },
provider: {
driverKind: "codex_app_server",
identity: { kind: "codex" },
},
normalizedSessionId: "session-1",
});
const root = join(base, createHash("sha256").update(scope).digest("hex"));
mkdirSync(join(root, "runner"), { recursive: true });
mkdirSync(join(root, "control-plane"));
if (kind === "live_provider") {
const startup =
f.snapshot.control.committedEvents[3]!.envelope.payload.payload
.startup;
startup.processId = process.pid;
startup.processGroupId = process.pid;
}
if (kind === "foreign_company") execution.binding.companyId = "other";
if (kind === "newer_lease") coordinator.leaseOwner = "active";
for (const [name, value] of [
["control-plane/control-plane-state.json", f.snapshot.control],
["runner/runner-state.json", f.snapshot.runner],
["runner/codex-provider-state.json", f.snapshot.provider],
] as const)
writeFileSync(join(root, name), JSON.stringify(value));
if (kind === "symlink" || kind === "missing_file") {
rmSync(join(root, "runner/codex-provider-state.json"));
if (kind === "symlink") {
writeFileSync(
join(base, "provider.json"),
JSON.stringify(f.snapshot.provider),
);
symlinkSync(
join(base, "provider.json"),
join(root, "runner/codex-provider-state.json"),
);
}
}
const kill =
kind === "permission_denied"
? vi.spyOn(process, "kill").mockImplementation(() => {
throw Object.assign(new Error("denied"), { code: "EPERM" });
})
: null;
try {
const result = verifyRetainedLocalProcessStop(run, coordinator);
if (kind === "stopped")
expect(result).toMatchObject({
providerProcessIds: [999999992, 999999993],
controllerPid: 999999999,
});
else expect(result).toBeNull();
} finally {
kill?.mockRestore();
if (previousBase === undefined)
delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
else process.env.PAPERCLIP_RUNNER_STATE_DIR = previousBase;
rmSync(base, { recursive: true, force: true });
}
},
30000,
);
});

View File

@ -0,0 +1,187 @@
import { validatePrpEvent } from "../../vendor/paperclip-runner/index.js";
import type {
RetainedMaintenanceIdentity,
RetainedMaintenanceSnapshot,
} from "./native-maintenance-no-launch.js";
const record = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("invalid");
return value as Record<string, unknown>;
};
const array = (value: unknown): unknown[] => {
if (!Array.isArray(value) || value.length > 100_000)
throw new Error("invalid");
return value;
};
function requireProof(value: unknown): asserts value {
if (!value) throw new Error("unproven");
}
const identityKeys = [
"runnerInstanceId",
"environmentLeaseId",
"runId",
"normalizedSessionId",
"turnId",
"itemId",
] as const;
/** Inventory only. The caller must separately verify local host ownership,
* terminal coordinator/runner exit, absent processes, and unchanged files.
* Missing launch records never count as evidence that a process stopped.
*/
export function retainedLocalProviderProcesses(input: {
snapshot: RetainedMaintenanceSnapshot;
identity: RetainedMaintenanceIdentity;
now: Date;
}): number[] | null {
try {
const { control, runner, provider } = input.snapshot;
const { identity } = input;
const now = input.now.getTime();
requireProof(Number.isFinite(now));
requireProof(
control.schema === "paperclip.runner.durable.control-plane-state.v1",
);
requireProof(runner.schema === "paperclip.runner.durable.state.v1");
requireProof(
provider.schema === "paperclip.runner.codex-provider-state.v1",
);
for (const key of identityKeys) {
requireProof(
typeof identity[key] === "string" && identity[key].length > 0,
);
requireProof(
record(control.identity)[key] === identity[key] &&
runner[key] === identity[key],
);
}
requireProof(
runner.lifecycle === "suspended" &&
runner.pendingTerminalDelivery === null,
);
requireProof(
runner.pendingProviderCleanup == null &&
array(runner.outbox).length === 0,
);
requireProof(
provider.lifecycle === "prepared" && provider.startupAttempt === null,
);
requireProof(
array(provider.pendingEvents).length === 0 &&
array(provider.queuedEvents).length === 0,
);
requireProof(
Object.keys(record(record(provider.toolBridge).pending)).length === 0,
);
requireProof(
provider.activeProviderTurnId === null &&
provider.ambiguousTurnStartPending === false,
);
// An old controller or a still-valid reconnect credential must not revive
// this authority after the process inventory has been checked.
for (const name of ["tickets", "leases"]) {
const credentials = Object.values(record(control[name]));
requireProof(credentials.length > 0 && credentials.length <= 4096);
for (const raw of credentials) {
const expiry = Date.parse(String(record(raw).expiresAt));
requireProof(Number.isFinite(expiry) && expiry <= now);
}
}
for (const raw of array(control.commands)) {
const command = record(raw);
requireProof(
["completed", "failed", "pending"].includes(String(command.status)),
);
if (command.status === "pending") {
requireProof(
["session.snapshot", "runner.suspend"].includes(String(command.type)),
);
requireProof(
Number(command.controllerSeq) >
Number(runner.lastControllerCommandSeq),
);
}
}
const events = array(control.committedEvents);
requireProof(events.length > 0 && control.ackedSourceSeq === events.length);
requireProof(
runner.ackedSourceSeq === events.length &&
runner.nextSourceSeq === events.length + 1,
);
const launches = new Map<
string,
{ generation: number; pid: number | null }
>();
let lastGeneration = 0;
for (const [index, raw] of events.entries()) {
const wrapper = record(raw);
const envelope = record(wrapper.envelope);
const event = record(envelope.payload);
requireProof(validatePrpEvent(event).ok);
for (const key of identityKeys)
requireProof(envelope[key] === identity[key]);
requireProof(
event.runId === identity.runId &&
event.normalizedSessionId === identity.normalizedSessionId,
);
requireProof(
event.sourceKind === "runner" &&
event.sourceInstanceId === identity.runnerInstanceId,
);
requireProof(
event.turnId === identity.turnId && event.itemId === identity.itemId,
);
requireProof(
event.sourceSeq === index + 1 && wrapper.sourceSeq === event.sourceSeq,
);
const startupValue = record(event.payload).startup;
if (startupValue === undefined) continue;
const startup = record(startupValue);
requireProof(
event.eventType === "harness.diagnostic" &&
startup.schema === "paperclip.provider_startup.v1",
);
for (const key of identityKeys.filter(
(key) => key !== "environmentLeaseId",
)) {
requireProof(record(startup.origin)[key] === identity[key]);
}
const launchId = startup.launchId;
const generation = startup.attemptedProcessGeneration;
requireProof(typeof launchId === "string" && launchId.length > 0);
requireProof(Number.isSafeInteger(generation) && Number(generation) > 0);
if (startup.phase === "intent") {
requireProof(
!launches.has(launchId) && Number(generation) > lastGeneration,
);
requireProof(
startup.processId === null && startup.processGroupId === null,
);
launches.set(launchId, { generation: Number(generation), pid: null });
lastGeneration = Number(generation);
} else {
requireProof(startup.phase === "spawned");
const launch = launches.get(launchId);
requireProof(
launch && launch.generation === generation && launch.pid === null,
);
requireProof(
Number.isSafeInteger(startup.processId) &&
Number(startup.processId) > 0,
);
requireProof(startup.processGroupId === startup.processId);
launch.pid = Number(startup.processId);
}
}
requireProof(
launches.size > 0 &&
provider.providerProcessGeneration === lastGeneration,
);
const processes = [...launches.values()].map((launch) => launch.pid);
requireProof(processes.every((pid): pid is number => pid !== null));
return [...new Set(processes)];
} catch {
return null;
}
}

View File

@ -22,6 +22,29 @@ function comment(id: string, body: string) {
}
describe("normalizeIssueQueuedCommentQueue", () => {
it("keeps the recovery explanation through normalization and optimistic merging", () => {
const wait = { reason: "process_identity_missing", message: "The previous run has no verified stop record." };
const authoritativeQueue = normalizeIssueQueuedCommentQueue({
state: "deferred", executionWait: wait,
entries: [{ comment: comment("saved", "continue"), position: 0 }],
}, "issue-1");
const queue = mergePendingIssueQueuedComments({ issueId: "issue-1", authoritativeQueue,
pendingComments: [{ comment: comment("new", "continue"), targetRunId: null }],
fallbackProtocol: "paperclip_runner_v1" });
expect(queue?.executionWait).toEqual(wait);
expect(queue?.entries).toHaveLength(2);
const running = normalizeIssueQueuedCommentQueue({ ...authoritativeQueue, state: "queued" }, "issue-1");
expect(running.executionWait).toBeNull();
expect(mergePendingIssueQueuedComments({ issueId: "issue-1", authoritativeQueue: running,
pendingComments: [], fallbackProtocol: "paperclip_runner_v1" })?.executionWait).toBeNull();
});
it.each([null, "wait", {}, { reason: 1, message: "Wait" }, { reason: "wait", message: " " }])(
"drops malformed execution wait data: %j", executionWait => {
expect(normalizeIssueQueuedCommentQueue({ state: "deferred", executionWait }, "issue-1").executionWait).toBeNull();
},
);
it("sorts, deduplicates, and drops malformed queue entries", () => {
const queue = normalizeIssueQueuedCommentQueue(
{

View File

@ -54,6 +54,7 @@ export function normalizeIssueQueuedCommentQueue(
.map((entry, position) => ({ ...entry, position }));
const disposition = source?.steeringDisposition;
const state = source?.state;
const wait = record(source?.executionWait);
return {
issueId:
@ -79,6 +80,10 @@ export function normalizeIssueQueuedCommentQueue(
)
? (disposition as IssueQueuedCommentSteeringDisposition)
: "unsupported",
executionWait:
state === "deferred" && typeof wait?.reason === "string" &&
typeof wait.message === "string" && wait.reason.trim() && wait.message.trim()
? { reason: wait.reason, message: wait.message } : null,
entries,
};
}
@ -144,6 +149,8 @@ export function mergePendingIssueQueuedComments(params: {
(protocol === "paperclip_runner_v1" && targetRunId
? "temporarily_unavailable"
: "unsupported"),
executionWait: params.authoritativeQueue?.state === "deferred"
? params.authoritativeQueue.executionWait ?? null : null,
entries,
};
}