fix: qualify Codex sandbox probe and diagnose retained native sessions

This commit is contained in:
Dotta 2026-09-11 14:19:06 -05:00
parent 3f55e74a22
commit bd59c07a21
5 changed files with 109 additions and 6 deletions

View File

@ -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");

View File

@ -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",

View File

@ -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");

View File

@ -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);

View File

@ -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,