fix(e2e): stabilize governed runner continuations

This commit is contained in:
Dotta committed 2026-08-27 13:36:04 -05:00
1 parent 5b8e31f3c4
commit ec2064bca2
4 files changed
+1010 -490

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -1043,6 +1043,60 @@ function providerSessionIdentityIsPresent(value: unknown): boolean {
);
}
export function providerSessionIdentityTransitionIsAllowed(input: {
execution: NativeExecutionInput;
previous: unknown;
current: unknown;
}): boolean {
if (canonicalJson(input.previous) === canonicalJson(input.current)) {
return true;
}
if (
input.execution.provider.kind !== "acpx" ||
input.execution.interactionResponses.length === 0
) {
return false;
}
const previousOuter = record(input.previous);
const currentOuter = record(input.current);
const previous = record(previousOuter.providerSessionIdentity);
const current = record(currentOuter.providerSessionIdentity);
if (previous.kind !== "acpx" || current.kind !== "acpx") return false;
const stableFields = [
"normalizedSessionId",
"profileDigest",
"workspaceDigest",
"requestedModel",
"effectiveModel",
"permissionMode",
] as const;
if (
current.normalizedSessionId !== nativeSessionKey(input.execution) ||
stableFields.some(
(field) =>
typeof previous[field] !== "string" ||
previous[field] !== current[field],
)
) {
return false;
}
return (
typeof previous.acpxRecordId === "string" &&
typeof previous.backendSessionId === "string" &&
typeof previous.agentSessionId === "string" &&
typeof current.acpxRecordId === "string" &&
typeof current.backendSessionId === "string" &&
typeof current.agentSessionId === "string" &&
previousOuter.providerSessionId === previous.acpxRecordId &&
previousOuter.providerBackendSessionId === previous.backendSessionId &&
currentOuter.providerSessionId === current.acpxRecordId &&
currentOuter.providerBackendSessionId === current.backendSessionId
);
}
function digestBackupDirectory(directory: string): {
sha256: string;
bytes: number;
@@ -1330,8 +1384,9 @@ export function nativeSessionFailureDisposition(
now = new Date(),
sourceFailureCode?: ReturnType<typeof nativeSessionFailureSourceCode>,
) {
const permanentFailure = sourceFailureCode === "native_event_replay_conflict"
|| sourceFailureCode === "runner_remote_provider_artifact_incompatible";
const permanentFailure =
sourceFailureCode === "native_event_replay_conflict" ||
sourceFailureCode === "runner_remote_provider_artifact_incompatible";
const exhausted = permanentFailure || attempt >= 3;
return {
phase: exhausted
@@ -1448,9 +1503,7 @@ const PROVIDER_DURABLE_EVENT_TYPES = new Set([
]);
type NativeRecoveryMode =
| "bootstrap_retry"
| "exact_checkpoint_resume"
| "ambiguous_state";
"bootstrap_retry" | "exact_checkpoint_resume" | "ambiguous_state";
export async function nativeProviderRecoveryEvidence(input: {
db: Db;
@@ -1472,9 +1525,9 @@ export async function nativeProviderRecoveryEvidence(input: {
const checkpointRecord = record(checkpoint);
const checkpointExists = Object.keys(checkpointRecord).length > 0;
const providerSessionEstablished =
(typeof checkpointRecord.providerSessionId === "string"
&& checkpointRecord.providerSessionId.length > 0)
|| Object.keys(record(checkpointRecord.providerIdentity)).length > 0;
(typeof checkpointRecord.providerSessionId === "string" &&
checkpointRecord.providerSessionId.length > 0) ||
Object.keys(record(checkpointRecord.providerIdentity)).length > 0;
const durableEvents = await input.db
.select({ eventType: heartbeatRunEvents.eventType })
.from(heartbeatRunEvents)
@@ -2681,15 +2734,18 @@ export async function executePaperclipNativeSession(input: {
now,
sourceFailureCode,
);
const phase = recoveryEvidence.recoveryMode === "ambiguous_state"
? ("terminal_failure" as const)
: disposition.phase;
const failureCode = recoveryEvidence.recoveryMode === "ambiguous_state"
? sourceFailureCode
: disposition.failureCode;
const nextAttemptAt = recoveryEvidence.recoveryMode === "ambiguous_state"
? null
: disposition.nextAttemptAt;
const phase =
recoveryEvidence.recoveryMode === "ambiguous_state"
? ("terminal_failure" as const)
: disposition.phase;
const failureCode =
recoveryEvidence.recoveryMode === "ambiguous_state"
? sourceFailureCode
: disposition.failureCode;
const nextAttemptAt =
recoveryEvidence.recoveryMode === "ambiguous_state"
? null
: disposition.nextAttemptAt;
const recoveryProjection = nativeSessionRecoveryProjection({
phase,
failureCode,
@@ -2719,15 +2775,16 @@ export async function executePaperclipNativeSession(input: {
providerEventsExist: recoveryEvidence.providerEventsExist,
checkpointExists: recoveryEvidence.checkpointExists,
recoveryOwner: recoveryProjection.recoveryOwner,
nextAction: recoveryEvidence.recoveryMode === "ambiguous_state"
? "Inspect the original provider failure and durable events; state is ambiguous and a replacement provider session is forbidden."
: integrityFailure
? "Inspect the persisted runner events and checkpoint for a source-sequence integrity conflict; automatic recovery is stopped."
: exhausted
? "Inspect the persisted native session after its bounded resume budget was exhausted."
: recoveryEvidence.recoveryMode === "bootstrap_retry"
? "Retry provider bootstrap on this same run; durable evidence proves no provider session or provider event was created."
: "Resume this same run from its exact persisted native provider checkpoint after the retry delay.",
nextAction:
recoveryEvidence.recoveryMode === "ambiguous_state"
? "Inspect the original provider failure and durable events; state is ambiguous and a replacement provider session is forbidden."
: integrityFailure
? "Inspect the persisted runner events and checkpoint for a source-sequence integrity conflict; automatic recovery is stopped."
: exhausted
? "Inspect the persisted native session after its bounded resume budget was exhausted."
: recoveryEvidence.recoveryMode === "bootstrap_retry"
? "Retry provider bootstrap on this same run; durable evidence proves no provider session or provider event was created."
: "Resume this same run from its exact persisted native provider checkpoint after the retry delay.",
},
nextAttemptAt,
updatedAt: now,
@@ -2778,15 +2835,16 @@ export async function executePaperclipNativeSession(input: {
providerSessionEstablished:
recoveryEvidence.providerSessionEstablished,
},
nextAction: recoveryEvidence.recoveryMode === "ambiguous_state"
? "Inspect the original provider failure and explicitly resolve the ambiguous session state; do not open a replacement provider session."
: integrityFailure
? "Inspect the persisted runner event collision and explicitly repair or replace the run; automatic retries are disabled."
: exhausted
? "Inspect the provider trace and explicitly choose a replacement run or provider configuration; automatic provider work is stopped."
: recoveryEvidence.recoveryMode === "bootstrap_retry"
? "Retry bootstrap on the same run without manufacturing a provider checkpoint."
: "Resume the exact persisted native session on the same heartbeat run.",
nextAction:
recoveryEvidence.recoveryMode === "ambiguous_state"
? "Inspect the original provider failure and explicitly resolve the ambiguous session state; do not open a replacement provider session."
: integrityFailure
? "Inspect the persisted runner event collision and explicitly repair or replace the run; automatic retries are disabled."
: exhausted
? "Inspect the provider trace and explicitly choose a replacement run or provider configuration; automatic provider work is stopped."
: recoveryEvidence.recoveryMode === "bootstrap_retry"
? "Retry bootstrap on the same run without manufacturing a provider checkpoint."
: "Resume the exact persisted native session on the same heartbeat run.",
wakePolicy: nextAttemptAt
? {
kind: "resume_native_run",
@@ -2990,8 +3048,7 @@ const RUNNERD_BUILD_METADATA_SCHEMA =
"paperclip-runner/runnerd-build-metadata/v1";
const RUNNERD_BINARY_CONTRACT_VERSION = 2;
const REMOTE_PROVIDER_PACK_SCHEMA =
"paperclip-runner/remote-provider-pack/v1";
const REMOTE_PROVIDER_PACK_SCHEMA = "paperclip-runner/remote-provider-pack/v1";
const REMOTE_PROVIDER_PACK_PINS = {
nodeMinimum: "24.11.0",
codex: "0.148.0",
@@ -3038,8 +3095,9 @@ function sha256File(path: string): string {
export function sha256DirectoryTree(root: string): string {
const hash = createHash("sha256");
const visit = (directory: string, prefix = "") => {
const entries = readdirSync(directory, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name));
const entries = readdirSync(directory, { withFileTypes: true }).sort(
(left, right) => left.name.localeCompare(right.name),
);
for (const entry of entries) {
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
const absolutePath = join(directory, entry.name);
@@ -3049,7 +3107,9 @@ export function sha256DirectoryTree(root: string): string {
} else if (entry.isFile()) {
hash.update(`file\0${relativePath}\0${sha256File(absolutePath)}\n`);
} else if (entry.isSymbolicLink()) {
hash.update(`symlink\0${relativePath}\0${readlinkSync(absolutePath)}\n`);
hash.update(
`symlink\0${relativePath}\0${readlinkSync(absolutePath)}\n`,
);
} else {
throw new Error(
`runner_remote_provider_artifact_incompatible: unsupported dist entry ${relativePath}`,
@@ -3063,12 +3123,12 @@ export function sha256DirectoryTree(root: string): string {
function providerPackRelativePath(value: unknown, field: string): string {
if (
typeof value !== "string"
|| value.length === 0
|| value.startsWith("/")
|| value.includes("\\")
|| posix.normalize(value) !== value
|| value.split("/").includes("..")
typeof value !== "string" ||
value.length === 0 ||
value.startsWith("/") ||
value.includes("\\") ||
posix.normalize(value) !== value ||
value.split("/").includes("..")
) {
throw new Error(
`runner_remote_provider_artifact_incompatible: invalid ${field}`,
@@ -3093,15 +3153,15 @@ export function readRemoteProviderPackManifest(
}
const payload = manifest?.payload;
if (
manifest.schema !== REMOTE_PROVIDER_PACK_SCHEMA
|| !payload
|| canonicalJson(payload.pins) !== canonicalJson(REMOTE_PROVIDER_PACK_PINS)
|| canonicalJson(payload.acpxProfileDigests)
!== canonicalJson(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS)
|| typeof payload.target?.platform !== "string"
|| typeof payload.target?.architecture !== "string"
|| !/^[0-9a-f]{40}(?:-dirty)?$/.test(payload.runnerSourceRevision)
|| !/^sha256:[0-9a-f]{64}$/.test(payload.distDigest)
manifest.schema !== REMOTE_PROVIDER_PACK_SCHEMA ||
!payload ||
canonicalJson(payload.pins) !== canonicalJson(REMOTE_PROVIDER_PACK_PINS) ||
canonicalJson(payload.acpxProfileDigests) !==
canonicalJson(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS) ||
typeof payload.target?.platform !== "string" ||
typeof payload.target?.architecture !== "string" ||
!/^[0-9a-f]{40}(?:-dirty)?$/.test(payload.runnerSourceRevision) ||
!/^sha256:[0-9a-f]{64}$/.test(payload.distDigest)
) {
throw new Error(
"runner_remote_provider_artifact_incompatible: provider pack pins or source revision do not match",
@@ -3129,8 +3189,8 @@ export function readRemoteProviderPackManifest(
`${label} path`,
);
if (
typeof artifact?.sha256 !== "string"
|| sha256File(resolve(packRoot, artifactPath)) !== artifact.sha256
typeof artifact?.sha256 !== "string" ||
sha256File(resolve(packRoot, artifactPath)) !== artifact.sha256
) {
throw new Error(
`runner_remote_provider_artifact_incompatible: ${label} digest mismatch`,
@@ -3612,17 +3672,17 @@ export function createRunnerdBackend(input: {
)
: null;
const requiresRemoteProviderPack =
remoteTarget !== null
&& (input.execution.provider.kind === "opencode"
|| input.execution.provider.kind === "acpx");
remoteTarget !== null &&
(input.execution.provider.kind === "opencode" ||
input.execution.provider.kind === "acpx");
const configuredProviderPackRoot =
input.runnerRemoteProviderPackPath?.trim() || null;
let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
if (requiresRemoteProviderPack) {
if (
!configuredProviderPackRoot
|| !existsSync(configuredProviderPackRoot)
|| !lstatSync(configuredProviderPackRoot).isDirectory()
!configuredProviderPackRoot ||
!existsSync(configuredProviderPackRoot) ||
!lstatSync(configuredProviderPackRoot).isDirectory()
) {
throw new Error(
"runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH with the build-owned provider pack",
@@ -3774,11 +3834,8 @@ export function createRunnerdBackend(input: {
};
const verifyRemoteProviderPack = async (packRoot: string) => {
if (
!remoteTarget
|| !remoteCommandRunner
|| !expectedProviderPackManifest
) return;
if (!remoteTarget || !remoteCommandRunner || !expectedProviderPackManifest)
return;
const expected = Buffer.from(
canonicalJson(expectedProviderPackManifest),
"utf8",
@@ -3833,9 +3890,9 @@ export function createRunnerdBackend(input: {
timeoutMs: 30_000,
});
if (
opencodeVersion.exitCode !== 0
|| opencodeVersion.timedOut
|| opencodeVersion.stdout.trim() !== REMOTE_PROVIDER_PACK_PINS.opencode
opencodeVersion.exitCode !== 0 ||
opencodeVersion.timedOut ||
opencodeVersion.stdout.trim() !== REMOTE_PROVIDER_PACK_PINS.opencode
) {
throw new Error(
"runner_remote_provider_artifact_incompatible: OpenCode version mismatch",
@@ -3849,7 +3906,7 @@ export function createRunnerdBackend(input: {
command: "sh",
args: [
"-c",
"for candidate in /opt/paperclip-runner/provider-pack \"$HOME/.local/share/paperclip-runner/provider-pack\"; do if [ -f \"$candidate/provider-pack.json\" ]; then printf '%s\\n' \"$candidate\"; break; fi; done",
'for candidate in /opt/paperclip-runner/provider-pack "$HOME/.local/share/paperclip-runner/provider-pack"; do if [ -f "$candidate/provider-pack.json" ]; then printf \'%s\\n\' "$candidate"; break; fi; done',
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
@@ -3929,8 +3986,10 @@ export function createRunnerdBackend(input: {
"runner.artifact.verify_preinstalled",
() => verifyRemoteRunner(requiredMode, preinstalledRunner),
);
await measureNativeRunnerSpan(input.trace, "runner.artifact.link", () =>
linkPreinstalledExecutable(preinstalledRunner, remoteBinary),
await measureNativeRunnerSpan(
input.trace,
"runner.artifact.link",
() => linkPreinstalledExecutable(preinstalledRunner, remoteBinary),
);
usedPreinstalledRunner = true;
await input.onLog?.(
@@ -4098,12 +4157,11 @@ export function createRunnerdBackend(input: {
);
}
if (
requiresRemoteProviderPack
&& configuredProviderPackRoot
&& stagedRemoteProviderPackRoot
requiresRemoteProviderPack &&
configuredProviderPackRoot &&
stagedRemoteProviderPackRoot
) {
let preinstalledProviderPack =
await discoverPreinstalledProviderPack();
let preinstalledProviderPack = await discoverPreinstalledProviderPack();
if (preinstalledProviderPack) {
try {
await measureNativeRunnerSpan(
@@ -4175,10 +4233,8 @@ export function createRunnerdBackend(input: {
targetPath: stagedRemoteProviderPackRoot,
mode: 0o700,
});
await measureNativeRunnerSpan(
input.trace,
"provider_pack.verify",
() => verifyRemoteProviderPack(stagedRemoteProviderPackRoot),
await measureNativeRunnerSpan(input.trace, "provider_pack.verify", () =>
verifyRemoteProviderPack(stagedRemoteProviderPackRoot),
);
activeRemoteProviderPackRoot = stagedRemoteProviderPackRoot;
}
@@ -4246,8 +4302,11 @@ export function createRunnerdBackend(input: {
})[0]?.manifest;
if (
previousManifest &&
canonicalJson(previousManifest.providerSessionIdentity) !==
canonicalJson(providerSessionIdentity)
!providerSessionIdentityTransitionIsAllowed({
execution: input.execution,
previous: previousManifest.providerSessionIdentity,
current: providerSessionIdentity,
})
) {
throw new Error("runner_harness_state_mismatch");
}
@@ -4444,7 +4503,8 @@ export function createRunnerdBackend(input: {
) {
const acquisitionRecordedAtMs = Date.now();
const backupAvailable = harnessBackupCandidates(root).some(
(candidate) => existsSync(resolve(candidate, "manifest.json")),
(candidate) =>
existsSync(resolve(candidate, "manifest.json")),
);
const restoreIntoCreatedSandbox =
shouldRestoreNativeHarnessBackupIntoSandbox({
@@ -4944,11 +5004,13 @@ export function createRunnerdBackend(input: {
),
opencodeCommand: posix.join(
stagedRemoteProviderPackRoot,
expectedProviderPackManifest.payload.artifacts.opencodeExecutable.path,
expectedProviderPackManifest.payload.artifacts
.opencodeExecutable.path,
),
opencodeProxyPath: posix.join(
stagedRemoteProviderPackRoot,
expectedProviderPackManifest.payload.artifacts.opencodeProxy.path,
expectedProviderPackManifest.payload.artifacts.opencodeProxy
.path,
),
acpxSidecarPath: posix.join(
stagedRemoteProviderPackRoot,
+2 -2
View File
@@ -397,7 +397,7 @@ export const runnerTasks: readonly RunnerTaskFixture[] = [
"Change the plan from two steps to exactly three numbered steps, with verification as step 3.",
"Publish the revised canonical Plan revision and request confirmation for that new revision.",
"In a native runner, call write_document for key `plan`, then call request_human_input exactly once with interactionKind `confirmation`, targetRevisionId set to the returned latest Plan revision, and continuationPolicy `wake_assignee`; do not call paperclip_finish while waiting.",
"In a legacy runner, update the Plan and create the equivalent revision-bound request_confirmation through the public Paperclip API.",
"In a legacy runner, first GET the current `plan` issue document, then PUT the revised Plan with `baseRevisionId` set to that response's `latestRevisionId`; after the update succeeds, create the equivalent request_confirmation targeting the newly returned `latestRevisionId` through the public Paperclip API.",
].join(" "),
buildPrompt: (nonce) =>
[
@@ -409,7 +409,7 @@ export const runnerTasks: readonly RunnerTaskFixture[] = [
`PAPERCLIP_E2E_PLAN_DONE_${nonce}` +
" and mark the task Done.",
"For a native runner, remain in the requested planning collaboration mode. Call write_document for key `plan`, then call request_human_input exactly once with interactionKind `confirmation`, targetRevisionId set to the returned latest Plan revision, and continuationPolicy `wake_assignee`. Do not call paperclip_finish while waiting for either Plan confirmation.",
"For a legacy runner, use the public Paperclip API: PUT the `plan` issue document, create a `request_confirmation` targeting its latest revision with `continuationPolicy: wake_assignee`, and move the issue to `in_review` while waiting.",
"For a legacy runner, use the public Paperclip API. The first PUT of the `plan` issue document creates it. For every later PUT, first GET the current document and set `baseRevisionId` to its `latestRevisionId`; a 409 means you must GET again and retry with the new latest revision. Create a `request_confirmation` targeting the successful PUT response's `latestRevisionId` with `continuationPolicy: wake_assignee`, and move the issue to `in_review` while waiting.",
"Do not create files, child tasks, or unrelated work, and do not expose credentials.",
].join("\n"),
buildMatchers(nonce, execution) {
+17 -5
View File
@@ -55,6 +55,7 @@ interface RunRecord {
retryOfRunId?: string | null;
runnerInstanceId?: string | null;
contextSnapshot?: Record<string, unknown> | null;
runnerProfileJson?: Record<string, unknown> | null;
usageJson?: Record<string, unknown> | null;
resultJson?: Record<string, unknown> | null;
error?: string | null;
@@ -314,7 +315,7 @@ for (const execution of executions) {
const captureFailureApiState = async () => {
if (!fixtures || !issue) return;
const capture = async <T,>(operation: () => Promise<T>) =>
const capture = async <T>(operation: () => Promise<T>) =>
operation().catch((error) => ({
evidenceCaptureError:
error instanceof Error ? error.message : String(error),
@@ -339,10 +340,7 @@ for (const execution of executions) {
),
]);
const taskRuns = Array.isArray(listedRuns)
? matchingRuns(
listedRuns,
"id" in currentIssue ? currentIssue : issue,
)
? matchingRuns(listedRuns, "id" in currentIssue ? currentIssue : issue)
: [];
const detailedRuns = await Promise.all(
taskRuns.map((candidate) =>
@@ -754,8 +752,22 @@ for (const execution of executions) {
(candidate) => comment.createdByRunId === candidate.id,
),
);
// A native run can reach its terminal status just before the accepted
// semantic result is projected into the issue comment list. The task UI
// already renders that accepted result immediately, so include its
// public run-detail summary in the message matcher input and retain the
// separate browser assertion below as the visible-source-of-truth check.
const semanticSummaries = selectedRuns.flatMap((candidate) => {
const profile = record(candidate.runnerProfileJson);
const checkpoint = record(profile.sessionCheckpoint);
const semanticResult = record(checkpoint.semanticResult);
return typeof semanticResult.summary === "string"
? [semanticResult.summary]
: [];
});
const message = agentComments
.map((comment) => comment.body ?? "")
.concat(semanticSummaries)
.join("\n");
const pendingInteractions = terminal.interactions.filter(
(interaction) => interaction.status === "pending",