From bd59c07a21d79eea42bddcb26f3d2ad911536628 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 14:19:06 -0500 Subject: [PATCH] fix: qualify Codex sandbox probe and diagnose retained native sessions --- .../runner-core/src/acpx_provider_backend.rs | 4 ++ .../runner-core/src/acpx_sidecar_transport.rs | 41 +++++++++++++++ .../tests/native_provider_backend.rs | 51 +++++++++++++++++++ tests/runner-e2e/chat-flow.ts | 12 +++-- tests/runner-e2e/codex-ci-sandbox.ts | 7 ++- 5 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 6140fd4348..d82beef68a 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -632,7 +632,9 @@ impl AcpxCommandExecutor { event_type: "run.terminal".to_owned(), priority: EventPriority::P0, payload: json!({ + "schema": "paperclip.prp.terminal.v1", "status": "failed", + "turnTerminalState": "failed", "runTerminalState": "failed", "reportedWorkDisposition": "unknown", "provider": "acpx", @@ -2229,6 +2231,8 @@ mod tests { assert_eq!(events[0].event_type, "turn.failed"); assert_eq!(events[0].payload["providerShutdownFailed"], true); assert_eq!(events[1].event_type, "run.terminal"); + assert_eq!(events[1].payload["schema"], "paperclip.prp.terminal.v1"); + assert_eq!(events[1].payload["turnTerminalState"], "failed"); let cleanup_error = recovered .shutdown() .expect_err("cleanup must not succeed while the original lifetime remains active"); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 55f7b25335..5a063ab684 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -642,6 +642,14 @@ fn response_error_classification(error: &ResponseError) -> &'static str { _ => {} } match error.message.as_str() { + "ACPX recovery identity conflicts with the immutable session configuration" => { + "recovery_configuration_mismatch" + } + "ACPX recovery identity does not match the persisted runtime record" => { + "recovery_identity_mismatch" + } + "ACPX provider lifetime lease is unavailable" => "provider_lifetime_unavailable", + "Managed Codex credential home already has an active lease" => "provider_lifetime_owned", "ACPX session handshake exceeded its admission deadline" => "session_handshake_timeout", "ACPX provider lifetime guardian exited before ownership transfer" => { "provider_guardian_exit" @@ -734,6 +742,39 @@ mod tests { )), "session_handshake_timeout" ); + for (message, classification) in [ + ( + "ACPX recovery identity conflicts with the immutable session configuration", + "recovery_configuration_mismatch", + ), + ( + "ACPX recovery identity does not match the persisted runtime record", + "recovery_identity_mismatch", + ), + ( + "ACPX provider lifetime lease is unavailable", + "provider_lifetime_unavailable", + ), + ] { + assert_eq!( + response_error_classification(&error("acpx_sidecar_command_failed", message)), + classification + ); + assert_eq!( + response_error_classification(&error( + "acpx_sidecar_command_failed", + &format!("{message}: private-provider-detail") + )), + "unclassified" + ); + } + assert_eq!( + response_error_classification(&error( + "acpx_sidecar_command_failed", + "Managed Codex credential home already has an active lease" + )), + "provider_lifetime_owned" + ); let admission_failures = [ ( "ACPX_RUNTIME_ADMISSION_VERIFICATION_TIMEOUT", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index a0b040cfd2..b64131d23f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -405,6 +405,57 @@ fn executes_a_qualified_acpx_profile_through_the_native_selector() { fs::remove_dir_all(directory).unwrap(); } +#[test] +fn resumes_an_idle_acpx_session_in_a_cold_replacement_runner() { + let directory = temporary_directory("acpx-cold-idle-recovery"); + let config = acpx_config(&directory, "turns-reserved-result-terminal"); + let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config); + executor + .execute(&command( + 1, + "run.prepare", + prepare_payload(&directory, "codex"), + )) + .unwrap(); + let original = executor + .execute(&command(2, "session.open", json!({}))) + .unwrap(); + executor + .execute(&command( + 3, + "turn.start", + json!({"text":"Acknowledge.", "turnId":"provider-turn-first"}), + )) + .unwrap(); + let events = executor.poll_events().unwrap(); + executor.acknowledge_events(events.len()).unwrap(); + executor + .execute(&command(4, "runner.suspend", json!({}))) + .unwrap(); + executor.shutdown().unwrap(); + drop(executor); + + let mut replacement_config = config.clone(); + replacement_config.run_id = "run-2".to_owned(); + replacement_config.turn_id = "turn-2".to_owned(); + let mut replacement = + NativeProviderCommandExecutor::with_runner_config(&directory, &replacement_config); + let mut payload = prepare_payload(&directory, "codex"); + payload["provider"]["runId"] = json!("run-2"); + let resumed = replacement + .execute(&command(1, "run.attach", payload)) + .unwrap(); + assert_eq!(resumed.result["status"], "resumed"); + assert_eq!( + resumed.result["providerSessionId"], + original.result["providerSessionId"] + ); + // Admission itself must preserve the provider identity before any new + // model turn. The fixture's scripted terminal events belong to run-1. + replacement.shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); +} + #[test] fn keeps_native_acpx_semantic_events_on_the_durable_controller_turn() { let directory = temporary_directory("acpx-durable-turn-correlation"); diff --git a/tests/runner-e2e/chat-flow.ts b/tests/runner-e2e/chat-flow.ts index 665825c00e..f1f28dbe97 100644 --- a/tests/runner-e2e/chat-flow.ts +++ b/tests/runner-e2e/chat-flow.ts @@ -245,7 +245,9 @@ export async function runChatFlow(input: { enableClassicTaskInterface: false, }); expect(await api.get(chatPath)).toBeNull(); - await page.goto(route); + // Cold Vite startup can keep unrelated assets loading after the chat is + // interactive. The composer assertion below verifies actual UI readiness. + await page.goto(route, { waitUntil: "domcontentloaded", timeout: 60_000 }); await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); expect(await api.get(chatPath)).toBeNull(); expect(await allRuns()).toHaveLength(0); @@ -261,7 +263,7 @@ export async function runChatFlow(input: { const initialId = issue!.id; const before = runs.filter((run) => !isResetRun(run))[0]!; await noTasks(); - await page.reload(); + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); if (caseId === "continuity-restart") { await turn( "What phrase did I just ask you to remember? Reply with the phrase only.", @@ -272,7 +274,7 @@ export async function runChatFlow(input: { ).toContain(secret); const count = runs.length; await input.restart(); - await page.reload(); + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); await idle(2); expect(runs).toHaveLength(count); await turn( @@ -365,7 +367,7 @@ export async function runChatFlow(input: { ).toEqual( oldComments.filter((c) => c.createdByRunId === cancelledId), ); - await page.reload(); + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); await expect( page.getByText("New session", { exact: true }), ).toHaveCount(1); @@ -677,7 +679,7 @@ export async function runChatFlow(input: { expect(projects[0]!.workspaces.filter((w) => w.repoUrl)).toHaveLength( 0, ); - await page.reload(); + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); await expect( page.getByRole("article", { name: /Project created:/ }), ).toHaveCount(1); diff --git a/tests/runner-e2e/codex-ci-sandbox.ts b/tests/runner-e2e/codex-ci-sandbox.ts index 15817e0437..4f840cf731 100644 --- a/tests/runner-e2e/codex-ci-sandbox.ts +++ b/tests/runner-e2e/codex-ci-sandbox.ts @@ -36,7 +36,12 @@ export async function prepareCodexCiSandbox(repositoryRoot: string, temporaryRoo execFileSync("sudo", ["-n", "apparmor_parser", "-r", profilePath], { timeout: 15_000, stdio: "pipe" }); const probeHome = path.join(temporaryRoot, "codex-sandbox-probe"); await mkdir(probeHome, { mode: 0o700 }); - execFileSync(binary, ["sandbox", "-C", temporaryRoot, "--", "/bin/true"], { + execFileSync(binary, [ + "sandbox", "--permission-profile", "paperclip-e2e-probe", + "-c", 'permissions.paperclip-e2e-probe.filesystem={":root"="read"}', + "-c", "permissions.paperclip-e2e-probe.network.enabled=false", + "-C", temporaryRoot, "--", "/bin/true", + ], { cwd: temporaryRoot, env: { PATH: process.env.PATH, CODEX_HOME: probeHome }, timeout: 15_000,