fix(runner): preserve provider startup classifications

This commit is contained in:
Dotta 2026-09-02 19:55:26 -05:00
parent ec9297bef7
commit 805177f7d6
6 changed files with 163 additions and 10 deletions

View File

@ -281,7 +281,7 @@ impl AcpxSidecarTransport {
"ACPX sidecar command {} was rejected (retryable={}, classification={})",
command.as_str(),
error.retryable,
response_error_classification(&error.message),
response_error_classification(&error),
),
)));
}
@ -597,8 +597,30 @@ fn redact_diagnostic(value: &str) -> String {
}
}
fn response_error_classification(message: &str) -> &'static str {
match message {
fn response_error_classification(error: &ResponseError) -> &'static str {
match error.code.as_str() {
"ACP_MODEL_UNSUPPORTED" => return "requested_model_unsupported",
"AGENT_STARTUP_FAILED" => return "agent_startup_failed",
"AGENT_STARTUP_FAILED.UNVERIFIED_MODULE" => return "agent_startup_unverified_module",
"AGENT_STARTUP_FAILED.MODULE_NOT_FOUND" => return "agent_startup_module_not_found",
"AGENT_STARTUP_FAILED.PERMISSION_DENIED" => return "agent_startup_permission_denied",
"AGENT_STARTUP_FAILED.FILE_NOT_FOUND" => return "agent_startup_file_not_found",
"AGENT_STARTUP_FAILED.SYNTAX_ERROR" => return "agent_startup_syntax_error",
"AGENT_STARTUP_FAILED.INVALID_ARGUMENT" => return "agent_startup_invalid_argument",
"AGENT_STARTUP_FAILED.NO_STDERR" => return "agent_startup_no_stderr",
"AGENT_STARTUP_FAILED.SIGNAL" => return "agent_startup_signal",
"AGENT_STARTUP_FAILED.EXIT_NONZERO" => return "agent_startup_exit_nonzero",
"AGENT_STARTUP_FAILED.OTHER" => return "agent_startup_other",
"AGENT_DISCONNECTED" => return "agent_disconnected",
"AUTH_REQUIRED" => return "authentication_required",
"SESSION_RESUME_REQUIRED" => return "session_resume_required",
"SESSION_MODE_REPLAY_FAILED" => return "session_mode_replay_failed",
"SESSION_MODEL_REPLAY_FAILED" => return "session_model_replay_failed",
"SESSION_CONFIG_OPTION_REPLAY_FAILED" => return "session_config_option_replay_failed",
"CLAUDE_ACP_SESSION_CREATE_TIMEOUT" => return "claude_session_create_timeout",
_ => {}
}
match error.message.as_str() {
"ACPX session handshake exceeded its admission deadline" => "session_handshake_timeout",
"ACPX provider lifetime guardian exited before ownership transfer" => {
"provider_guardian_exit"
@ -606,6 +628,20 @@ fn response_error_classification(message: &str) -> &'static str {
"ACPX provider lifetime guardian ownership timed out" => "provider_guardian_timeout",
"ACPX session handshake and runtime cleanup failed" => "session_handshake_cleanup_failed",
"ACPX runtime initialization and cleanup failed" => "runtime_initialization_cleanup_failed",
_ if error
.message
.starts_with("ACP agent exited before initialize completed") =>
{
"agent_startup_failed"
}
_ if error.message.starts_with("Failed to spawn agent command:") => "agent_spawn_failed",
_ if error
.message
.starts_with("ACP agent disconnected during request") =>
{
"agent_disconnected"
}
_ if error.message.starts_with("Authentication required") => "authentication_required",
_ => "unclassified",
}
}
@ -658,12 +694,31 @@ mod tests {
#[test]
fn classifies_only_allowlisted_internal_sidecar_failures() {
let error = |code: &str, message: &str| ResponseError {
code: code.to_owned(),
message: message.to_owned(),
retryable: false,
};
assert_eq!(
response_error_classification("ACPX session handshake exceeded its admission deadline"),
response_error_classification(&error(
"acpx_sidecar_command_failed",
"ACPX session handshake exceeded its admission deadline",
)),
"session_handshake_timeout"
);
assert_eq!(
response_error_classification("violet-circuit-4821"),
response_error_classification(&error("ACP_MODEL_UNSUPPORTED", "violet-circuit-4821",)),
"requested_model_unsupported"
);
assert_eq!(
response_error_classification(&error(
"acpx_sidecar_command_failed",
"ACP agent exited before initialize completed (exit=1, signal=null): violet-circuit-4821",
)),
"agent_startup_failed"
);
assert_eq!(
response_error_classification(&error("VIOLET_CIRCUIT", "violet-circuit-4821")),
"unclassified"
);
}

View File

@ -15,8 +15,8 @@ use crate::durable::QualifiedLaunchArtifact;
use crate::durable::{redact_text, OpenCodeLaunchProfile};
use crate::local_runner::LocalRunnerError;
use crate::process_supervisor::{
BoundedLogBuffer, ProcessOutput, SupervisedProcess, VerifiedProcessArgument,
VerifiedProcessLaunch,
is_node_interpreter, BoundedLogBuffer, ProcessOutput, SupervisedProcess,
VerifiedProcessArgument, VerifiedProcessLaunch,
};
use crate::provider_bridge::{AuthorizedTool, DurableReplayFilter, ToolResult};
use crate::provider_events::normalized_codex_terminal_event_type;
@ -1994,8 +1994,16 @@ fn verified_opencode_launch(
.map_err(|error| LocalRunnerError::invalid(error.to_string()))?;
let executable = verify_launch_artifact(&profile.executable, "OpenCode provider executable")
.map_err(|error| LocalRunnerError::invalid(error.to_string()))?;
let proxy = if is_node_interpreter(&profile.command.path) {
VerifiedProcessArgument::CommonJsArtifact(proxy)
} else {
// Qualified test and alternate proxy commands own their ordinary
// argv contract. Only Node understands the runner-owned CommonJS
// descriptor loader flags.
VerifiedProcessArgument::Artifact(proxy)
};
let args = vec![
VerifiedProcessArgument::CommonJsArtifact(proxy),
proxy,
VerifiedProcessArgument::Literal(TRUSTED_OPENCODE_EXECUTABLE_ARG.to_owned()),
VerifiedProcessArgument::ExecutableArtifact(executable),
];

View File

@ -29,6 +29,12 @@ const PROCESS_OUTPUT_QUEUE_CAPACITY: usize = 256;
const VERIFIED_RUNTIME_EXECUTABLE_ENV: &str = "PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE";
const VERIFIED_COMMONJS_ARTIFACT_LOADER: &str = r#"const fs=require("node:fs");const Module=require("node:module");const filename=process.argv[1];const source=fs.readFileSync(filename,"utf8").replace(/^#![^\r\n]*(?:\r?\n|$)/,"");const artifact=new Module(filename);artifact.filename=filename;artifact.paths=[];artifact._compile(source,filename);"#;
pub(crate) fn is_node_interpreter(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, "node" | "nodejs" | "node.exe"))
}
#[derive(Clone, Debug)]
pub struct VerifiedProcessArtifact {
display_path: PathBuf,

View File

@ -54,6 +54,7 @@ import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js";
import type { RunnerToolCall } from "../drivers/runner-tool-bridge.js";
import {
acpxBootstrapBlockedError,
acpxSidecarErrorCode,
enqueueAcpxSidecarInput,
recordAcpxBootstrapFailure,
} from "./acpx-sidecar-input.js";
@ -182,15 +183,19 @@ async function receiveLine(line: string): Promise<void> {
} catch (error) {
const normalized =
error instanceof Error ? error : new Error(String(error));
const normalizedRecord = record(normalized);
bootstrapFailure = recordAcpxBootstrapFailure(
bootstrapFailure,
request.command,
normalized,
);
response(request.id, false, undefined, {
code: safeCode(record(normalized).code, "acpx_sidecar_command_failed"),
code: safeCode(
acpxSidecarErrorCode(normalized),
"acpx_sidecar_command_failed",
),
message: safeMessage(normalized),
retryable: record(normalized).retryable === true,
retryable: normalizedRecord.retryable === true,
});
}
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
acpxBootstrapBlockedError,
acpxSidecarErrorCode,
enqueueAcpxSidecarInput,
recordAcpxBootstrapFailure,
} from "./acpx-sidecar-input.js";
@ -112,4 +113,33 @@ describe("ACPX sidecar input sequencing", () => {
).toBeNull();
expect(acpxBootstrapBlockedError(null, "turn.start")).toBeNull();
});
it("preserves stable ACPX error identities without copying startup stderr", () => {
const missingModule = Object.assign(new Error("provider exited"), {
detailCode: "AGENT_STARTUP_FAILED",
stderrSummary:
"Error [ERR_MODULE_NOT_FOUND]: violet-circuit-4821 was not found",
exitCode: 1,
});
const opaqueExit = Object.assign(new Error("provider exited"), {
detailCode: "AGENT_STARTUP_FAILED",
stderrSummary: "violet-circuit-4821",
exitCode: 1,
});
const model = Object.assign(new Error("model rejected"), {
code: "ACP_MODEL_UNSUPPORTED",
detailCode: "AGENT_STARTUP_FAILED",
});
expect(acpxSidecarErrorCode(missingModule)).toBe(
"AGENT_STARTUP_FAILED.MODULE_NOT_FOUND",
);
expect(acpxSidecarErrorCode(opaqueExit)).toBe(
"AGENT_STARTUP_FAILED.EXIT_NONZERO",
);
expect(acpxSidecarErrorCode(opaqueExit)).not.toContain(
"violet-circuit-4821",
);
expect(acpxSidecarErrorCode(model)).toBe("ACP_MODEL_UNSUPPORTED");
});
});

View File

@ -28,3 +28,52 @@ export function acpxBootstrapBlockedError(
)
: null;
}
/**
* Preserve only stable ACPX/provider error identities across the sidecar
* boundary. Startup stderr can contain credentials or provider output, so it
* contributes a closed category and is never copied into the code itself.
*/
export function acpxSidecarErrorCode(error: Error): string {
const details = error as Error & Record<string, unknown>;
const code =
typeof details.code === "string"
? details.code
: typeof details.detailCode === "string"
? details.detailCode
: "acpx_sidecar_command_failed";
if (code !== "AGENT_STARTUP_FAILED") return code;
const stderr =
typeof details.stderrSummary === "string" ? details.stderrSummary : "";
if (/ERR_ACPX_UNVERIFIED_MODULE/.test(stderr)) {
return "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE";
}
if (/ERR_MODULE_NOT_FOUND|Cannot find (?:module|package)/i.test(stderr)) {
return "AGENT_STARTUP_FAILED.MODULE_NOT_FOUND";
}
if (/\bEACCES\b|permission denied/i.test(stderr)) {
return "AGENT_STARTUP_FAILED.PERMISSION_DENIED";
}
if (/\bENOENT\b|no such file or directory/i.test(stderr)) {
return "AGENT_STARTUP_FAILED.FILE_NOT_FOUND";
}
if (/SyntaxError|unexpected token/i.test(stderr)) {
return "AGENT_STARTUP_FAILED.SYNTAX_ERROR";
}
if (/ERR_INVALID_ARG|invalid argument/i.test(stderr)) {
return "AGENT_STARTUP_FAILED.INVALID_ARGUMENT";
}
if (!stderr.trim()) return "AGENT_STARTUP_FAILED.NO_STDERR";
if (typeof details.signal === "string" && details.signal) {
return "AGENT_STARTUP_FAILED.SIGNAL";
}
if (
typeof details.exitCode === "number" &&
Number.isInteger(details.exitCode) &&
details.exitCode !== 0
) {
return "AGENT_STARTUP_FAILED.EXIT_NONZERO";
}
return "AGENT_STARTUP_FAILED.OTHER";
}