fix(runner): preserve provider startup classifications
This commit is contained in:
parent
ec9297bef7
commit
805177f7d6
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue