mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
fix(acpx): report how the sandbox agent process session ended (#16366)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Hosted `claude_local` runs use the ACPX engine. The agent process
runs in a sandbox, and a host-side process session bridge carries the
ACP stream.
> - Some hosted turns fail with only "The connection to Claude was
lost." (`acpx_turn_failed`, terminal session category `connection`). The
failure report shows one pending `execute` tool. It does not show why
the agent process went away.
> - The bridge already records how the session ended: the remote exit
code and signal, or a host proxy close. But it writes that evidence only
to the run log. The run failure report never receives it.
> - Without it, we cannot tell an OOM kill (`SIGKILL`) or a provider
process exit from a bridge or proxy close. Each cause needs a different
fix, so we must not guess.
> - This pull request keeps the first terminal evidence on the bridge
handle and copies it, as closed values, into the run failure report's
execution context.
> - The benefit is that the next recurrence names the cause, and the
correct fix can follow.
## Linked Issues or Issue Description
**What happened?**
Hosted `claude_local` runs on staging and production failed with "The
connection to Claude was lost." One run failed about 4 minutes in, 7–10
seconds after its last ACP event. Another failed after about 3.5 hours
with no ACP events for most of that time. Both had one pending `execute`
tool. The report had no information about how the sandbox agent process
ended.
**Expected behavior**
The run failure report says how the sandbox agent process session ended.
Examples: the remote process exited with a code, the remote process was
killed by a signal, or the host proxy closed.
**Steps to reproduce**
1. Start a hosted ACP `claude_local` run on a sandbox target.
2. Kill the agent process in the sandbox while a tool runs, for example
with `kill -9`.
3. See that the run log has an `ACP process session terminal:` line, but
the run failure report has no matching field.
**Paperclip version or commit**
`master` at 1b58ac6.
## What Changed
- `startAdapterExecutionTargetProcessSessionBridge` keeps the first
terminal evidence it records (`source`, `outcome`, `exitCode`,
`signal`). The new optional handle method `readTerminalEvidence()`
returns it. The run log line does not change.
- The closed source and signal labels move to
`acpx-engine/constants.ts`. The bridge and the server reader use the
same list.
- The ACPX engine adds `acpProcessTerminalSource`,
`acpProcessTerminalOutcome`, `acpProcessTerminalExitCode` and
`acpProcessTerminalSignal` to the turn activity diagnostics in
`resultJson`. It leaves out an unknown exit code or signal.
- `collectRunFailureDiagnostics` copies these fields into the execution
context. It accepts only closed labels and an integer exit code from 0
to 255. If the source is not a closed label, it drops all the fields.
- `doc/observability.md` documents the new fields next to the other ACP
activity fields.
- Tests: bridge evidence for a remote exit, a missing command, and an
authenticated proxy close; the engine summary helper; and the server
reader with valid and invalid values.
## Verification
- `vitest run src/acpx-engine/ src/execution-target-sandbox.test.ts` in
`packages/adapter-utils`: 22 files, 579 tests pass.
- `vitest run src/services/__tests__/run-failure-diagnostics.test.ts` in
`server`: 121 tests pass.
- `tsc --noEmit -p packages/adapter-utils`: passes.
- I type-checked the changed server files with a narrow config. The only
errors came from workspace packages that were not built locally
(`@paperclipai/plugin-sdk`, `@paperclipai/paperclip-runner`). A full
server `tsc` ran out of memory in my sandbox. CI runs the full check.
## Risks
- This change adds diagnostics only. It does not change retry, recovery,
or classification behavior.
- Every new field is a closed label or a bounded integer. No message,
command, path, payload, or provider text goes into the report.
- The host records a `proxy_close` from a socket close event. If the
turn finalizes before that event, the fields are absent for that run.
When the fields are absent, the report has the same data as it has
today.
- `readTerminalEvidence` is optional on the handle type, so other handle
implementations and test doubles still compile.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use
and code execution. It read the repository, traced the failure path,
edited the code, and ran the tests.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Related: #16360 bounds hung MCP tool calls (ACP kind `other`). This
change is separate. It covers the sandbox agent process ending while a
tool runs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: devinfoley <devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
8 files changed
+206
-13
No files matched your search
@@ -1386,6 +1386,18 @@ different tools with different permission rules. Read them this way:
|
||||
|
||||
Both fields hold only counts.
|
||||
|
||||
A sandbox ACP run also records how its agent process session ended, when the
|
||||
host bridge saw it end before finalization. Startup failures (`create_runtime`,
|
||||
`ensure_session`, `configure_session`) carry the same fields, so an agent process
|
||||
that dies during the handshake is reported too. `acpProcessTerminalSource` is one of
|
||||
`remote_event`, `input_delivery`, `output_poll`, `output_stream`, or
|
||||
`proxy_close`, and `acpProcessTerminalOutcome` is `exit` or `error`.
|
||||
`acpProcessTerminalExitCode` (0–255) and `acpProcessTerminalSignal` (a closed
|
||||
POSIX signal name) are present only when known. These are the same values as the
|
||||
run log's `ACP process session terminal:` line. They separate a killed or exited
|
||||
agent process from a host proxy close after a generic ACP connection failure. The
|
||||
server drops all of them if the source is not a closed label.
|
||||
|
||||
ACP and legacy OpenCode results retain the adapter's resolved wall-clock timeout as
|
||||
`adapterExecutionTimeout` in the instance run result. Finalization uses it for
|
||||
`effectiveTimeoutSec`, `timeoutSource`, and `timeoutConfigured`. Sources are
|
||||
|
||||
@@ -57,6 +57,17 @@ export const ACP_TOOL_KINDS = [
|
||||
"read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other",
|
||||
] as const;
|
||||
|
||||
// Closed labels for how a sandbox agent process session ended. Run failure
|
||||
// reports copy only these values.
|
||||
export const ACP_PROCESS_TERMINAL_SOURCES = [
|
||||
"remote_event", "input_delivery", "output_poll", "output_stream", "proxy_close",
|
||||
] as const;
|
||||
|
||||
export const ACP_PROCESS_TERMINAL_SIGNALS = [
|
||||
"SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGABRT", "SIGFPE", "SIGKILL",
|
||||
"SIGSEGV", "SIGPIPE", "SIGALRM", "SIGTERM", "SIGBUS", "SIGXCPU", "SIGXFSZ",
|
||||
] as const;
|
||||
|
||||
export const ACPX_ADAPTER_AGENT_IDS = {
|
||||
claude_local: "claude",
|
||||
codex_local: "codex",
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
referencedSourceContentSignature,
|
||||
resolveStalledToolTimeoutMs,
|
||||
rewriteGeminiAcpFlagForVersion,
|
||||
summarizeAcpProcessTerminal,
|
||||
summarizeAcpStopActivity,
|
||||
summarizeAcpxTurnUsage,
|
||||
type AcpxEngineExecutorOptions,
|
||||
@@ -4034,6 +4035,39 @@ describe("summarizeAcpStopActivity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeAcpProcessTerminal", () => {
|
||||
it("reports nothing before the process session records terminal evidence", () => {
|
||||
expect(summarizeAcpProcessTerminal(null)).toEqual({});
|
||||
expect(summarizeAcpProcessTerminal(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("reports a signal-killed remote process", () => {
|
||||
expect(summarizeAcpProcessTerminal({
|
||||
source: "remote_event", outcome: "exit", exitCode: "unknown", signal: "SIGKILL",
|
||||
})).toEqual({
|
||||
acpProcessTerminalSource: "remote_event",
|
||||
acpProcessTerminalOutcome: "exit",
|
||||
acpProcessTerminalSignal: "SIGKILL",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an exit code and omits an unknown signal", () => {
|
||||
expect(summarizeAcpProcessTerminal({
|
||||
source: "remote_event", outcome: "exit", exitCode: 137, signal: "unknown",
|
||||
})).toEqual({
|
||||
acpProcessTerminalSource: "remote_event",
|
||||
acpProcessTerminalOutcome: "exit",
|
||||
acpProcessTerminalExitCode: 137,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a host proxy close with no exit details", () => {
|
||||
expect(summarizeAcpProcessTerminal({
|
||||
source: "proxy_close", outcome: "error", exitCode: "unknown", signal: "unknown",
|
||||
})).toEqual({ acpProcessTerminalSource: "proxy_close", acpProcessTerminalOutcome: "error" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeAcpxTurnUsage", () => {
|
||||
it("uses the post-turn amount alone when the cumulative cost counter reset", () => {
|
||||
const summary = summarizeAcpxTurnUsage({
|
||||
@@ -8130,12 +8164,13 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
|
||||
runtime: unknown,
|
||||
sandbox: Awaited<ReturnType<typeof setupRemoteSandbox>>,
|
||||
deps: Partial<AcpxEngineExecutorOptions> = {},
|
||||
processSessionBridge: Record<string, unknown> = {},
|
||||
) {
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
|
||||
async () => handle as never,
|
||||
);
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
||||
async () => ({ agentCommand: null, stop: async () => {} }) as never,
|
||||
async () => ({ agentCommand: null, stop: async () => {}, ...processSessionBridge }) as never,
|
||||
);
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () => runtime as never,
|
||||
@@ -8350,6 +8385,36 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
|
||||
expect(result.errorCode).not.toBe("acpx_handshake_timeout");
|
||||
}, 10000);
|
||||
|
||||
it("reports how the agent process session ended when the process exits during the handshake", async () => {
|
||||
const sandbox = await setupRemoteSandbox();
|
||||
const fake = createFakeBridgeHandle();
|
||||
const runtime = {
|
||||
ensureSession: async () => {
|
||||
throw new Error("agent process exited during initialize");
|
||||
},
|
||||
startTurn: () => {
|
||||
throw new Error("startTurn must not run after a failed handshake");
|
||||
},
|
||||
setConfigOption: async () => {},
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
const result = await runRemote(fake.handle, runtime, sandbox, {}, {
|
||||
readTerminalEvidence: () => ({
|
||||
source: "remote_event", outcome: "exit", exitCode: 137, signal: "SIGKILL",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.resultJson).toMatchObject({
|
||||
phase: "ensure_session",
|
||||
acpProcessTerminalSource: "remote_event",
|
||||
acpProcessTerminalOutcome: "exit",
|
||||
acpProcessTerminalExitCode: 137,
|
||||
acpProcessTerminalSignal: "SIGKILL",
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it("aborts an in-flight turn and fails the run when the duplex channel latches a loss mid-turn", async () => {
|
||||
const sandbox = await setupRemoteSandbox();
|
||||
const fake = createFakeBridgeHandle();
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type AdapterExecutionTarget,
|
||||
type AdapterExecutionTargetPaperclipBridgeHandle,
|
||||
type AdapterExecutionTargetProcessSessionBridgeHandle,
|
||||
type AdapterExecutionTargetProcessSessionTerminalEvidence,
|
||||
type AdapterExecutionTargetTimeoutResolution,
|
||||
type AdapterManagedRuntimeAsset,
|
||||
type PreparedAdapterExecutionTargetRuntime,
|
||||
@@ -3318,6 +3319,28 @@ function isPendingAcpTool(tool: { status?: string }): boolean {
|
||||
return tool.status !== "completed" && tool.status !== "failed" && tool.status !== "cancelled";
|
||||
}
|
||||
|
||||
/**
|
||||
* How the sandbox agent process session ended, when the bridge saw it end.
|
||||
* Separates a process that exited or was killed from a host proxy that closed
|
||||
* by itself. Unknown exit codes and signals are left out.
|
||||
*/
|
||||
export function summarizeAcpProcessTerminal(
|
||||
evidence: AdapterExecutionTargetProcessSessionTerminalEvidence | null | undefined,
|
||||
): {
|
||||
acpProcessTerminalSource?: AdapterExecutionTargetProcessSessionTerminalEvidence["source"];
|
||||
acpProcessTerminalOutcome?: AdapterExecutionTargetProcessSessionTerminalEvidence["outcome"];
|
||||
acpProcessTerminalExitCode?: number;
|
||||
acpProcessTerminalSignal?: string;
|
||||
} {
|
||||
if (!evidence) return {};
|
||||
return {
|
||||
acpProcessTerminalSource: evidence.source,
|
||||
acpProcessTerminalOutcome: evidence.outcome,
|
||||
...(typeof evidence.exitCode === "number" ? { acpProcessTerminalExitCode: evidence.exitCode } : {}),
|
||||
...(evidence.signal !== "unknown" ? { acpProcessTerminalSignal: evidence.signal } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity as of the moment the turn's stop fired, for a stopped turn only.
|
||||
* Separates a turn that went silent long before its deadline from one that
|
||||
@@ -4783,7 +4806,10 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
||||
...referencedProjectStagingFailuresField,
|
||||
model: prepared.requestedModel || null,
|
||||
clearSession,
|
||||
resultJson: { phase: "ensure_session" },
|
||||
resultJson: {
|
||||
phase: "ensure_session",
|
||||
...summarizeAcpProcessTerminal(prepared.processSessionBridge?.readTerminalEvidence?.()),
|
||||
},
|
||||
summary: message,
|
||||
};
|
||||
return settleFor("handshake", err);
|
||||
@@ -4813,7 +4839,10 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
||||
...billingFields,
|
||||
...referencedProjectStagingFailuresField,
|
||||
model: prepared.requestedModel || null,
|
||||
resultJson: { phase: "ensure_session" },
|
||||
resultJson: {
|
||||
phase: "ensure_session",
|
||||
...summarizeAcpProcessTerminal(prepared.processSessionBridge?.readTerminalEvidence?.()),
|
||||
},
|
||||
summary: "ACPX did not return a runtime session handle.",
|
||||
};
|
||||
return settleFor(
|
||||
@@ -4853,7 +4882,10 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
||||
...referencedProjectStagingFailuresField,
|
||||
model: prepared.requestedModel || null,
|
||||
clearSession,
|
||||
resultJson: { phase: "create_runtime" },
|
||||
resultJson: {
|
||||
phase: "create_runtime",
|
||||
...summarizeAcpProcessTerminal(prepared.processSessionBridge?.readTerminalEvidence?.()),
|
||||
},
|
||||
summary: message,
|
||||
};
|
||||
return settleFor("runtime_create", err);
|
||||
@@ -4910,6 +4942,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
||||
requestedModel: prepared.requestedModel || null,
|
||||
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
|
||||
fastMode: prepared.fastMode,
|
||||
...summarizeAcpProcessTerminal(prepared.processSessionBridge?.readTerminalEvidence?.()),
|
||||
},
|
||||
summary: message,
|
||||
};
|
||||
@@ -5282,6 +5315,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
||||
acpPendingToolCount: pendingTools.length,
|
||||
acpToolInventoryComplete: !incompleteToolInventory,
|
||||
...summarizeAcpStopActivity(turnStopRequestedAt, lastRuntimeEventBeforeStopAt, pendingToolsAtStop ?? pendingTools),
|
||||
...summarizeAcpProcessTerminal(prepared.processSessionBridge?.readTerminalEvidence?.()),
|
||||
};
|
||||
if (input.kind === "terminal") {
|
||||
const terminal = input.terminal;
|
||||
|
||||
@@ -1126,6 +1126,12 @@ describe("sandbox adapter execution targets", () => {
|
||||
expect(terminal).toEqual([
|
||||
`[paperclip] ACP process session terminal: source=remote_event outcome=${exitCode === null ? "error" : "exit"} exitCode=${exitCode ?? "unknown"} signal=unknown.\n`,
|
||||
]);
|
||||
expect(bridge!.readTerminalEvidence?.()).toEqual({
|
||||
source: "remote_event",
|
||||
outcome: exitCode === null ? "error" : "exit",
|
||||
exitCode: exitCode ?? "unknown",
|
||||
signal: "unknown",
|
||||
});
|
||||
if (exitCode === null) {
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toContain("ENOENT");
|
||||
@@ -1313,6 +1319,9 @@ describe("sandbox adapter execution targets", () => {
|
||||
if (!authenticated) expect(logs).toEqual([]);
|
||||
}
|
||||
await waitForCondition(() => logs.length === 1, "Missing proxy-close diagnostic.");
|
||||
expect(bridge!.readTerminalEvidence?.()).toEqual({
|
||||
source: "proxy_close", outcome: "error", exitCode: "unknown", signal: "unknown",
|
||||
});
|
||||
await bridge!.stop();
|
||||
expect(logs.filter((line) => line.includes("ACP process session terminal:"))).toEqual([
|
||||
"[paperclip] ACP process session terminal: source=proxy_close outcome=error exitCode=unknown signal=unknown.\n",
|
||||
|
||||
@@ -7,6 +7,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { githubLauncherSource } from "./github-launcher.js";
|
||||
import { ACP_PROCESS_TERMINAL_SIGNALS, type ACP_PROCESS_TERMINAL_SOURCES } from "./acpx-engine/constants.js";
|
||||
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import {
|
||||
prepareCommandManagedRuntime,
|
||||
@@ -377,8 +378,19 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
// First terminal evidence a sandbox process session observed. Every field is a
|
||||
// closed value: no message, command, path, payload, or provider error text.
|
||||
export interface AdapterExecutionTargetProcessSessionTerminalEvidence {
|
||||
source: (typeof ACP_PROCESS_TERMINAL_SOURCES)[number];
|
||||
outcome: "exit" | "error";
|
||||
exitCode: number | "unknown";
|
||||
signal: (typeof ACP_PROCESS_TERMINAL_SIGNALS)[number] | "unknown";
|
||||
}
|
||||
|
||||
export interface AdapterExecutionTargetProcessSessionBridgeHandle {
|
||||
agentCommand: string;
|
||||
// Returns null until the session records terminal evidence.
|
||||
readTerminalEvidence?(): AdapterExecutionTargetProcessSessionTerminalEvidence | null;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2237,22 +2249,19 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
}
|
||||
};
|
||||
let pollTimer: NodeJS.Timeout | null = null;
|
||||
let terminalEvidenceRecorded = false;
|
||||
let terminalEvidence: AdapterExecutionTargetProcessSessionTerminalEvidence | null = null;
|
||||
const recordTerminalEvidence = (
|
||||
source: "remote_event" | "input_delivery" | "output_poll" | "output_stream" | "proxy_close",
|
||||
source: AdapterExecutionTargetProcessSessionTerminalEvidence["source"],
|
||||
event: { type?: string; code?: number | null; signal?: string | null },
|
||||
) => {
|
||||
if (terminalEvidenceRecorded) return;
|
||||
terminalEvidenceRecorded = true;
|
||||
if (terminalEvidence) return;
|
||||
// Remote frames are untrusted. Keep the retained diagnostic bounded and
|
||||
// independent of messages, commands, paths, payloads, and provider errors.
|
||||
const outcome = event.type === "exit" ? "exit" : "error";
|
||||
const code = typeof event.code === "number" && Number.isInteger(event.code)
|
||||
&& event.code >= 0 && event.code <= 255 ? event.code : "unknown";
|
||||
const signal = typeof event.signal === "string" && [
|
||||
"SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGABRT", "SIGFPE", "SIGKILL",
|
||||
"SIGSEGV", "SIGPIPE", "SIGALRM", "SIGTERM", "SIGBUS", "SIGXCPU", "SIGXFSZ",
|
||||
].includes(event.signal) ? event.signal : "unknown";
|
||||
const signal = ACP_PROCESS_TERMINAL_SIGNALS.find((name) => name === event.signal) ?? "unknown";
|
||||
terminalEvidence = { source, outcome, exitCode: code, signal };
|
||||
logFailureWithoutWaiting(
|
||||
`[paperclip] ACP process session terminal: source=${source} outcome=${outcome} exitCode=${code} signal=${signal}.\n`,
|
||||
);
|
||||
@@ -2631,6 +2640,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
|
||||
return {
|
||||
agentCommand,
|
||||
readTerminalEvidence: () => terminalEvidence,
|
||||
stop: async () => {
|
||||
stopping = true;
|
||||
// End the `sandbox.agentProcess` span now, before the caller ends the run
|
||||
|
||||
@@ -481,6 +481,37 @@ describe("run failure diagnostics", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("includes how the sandbox agent process session ended", () => {
|
||||
const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: {
|
||||
acpProcessTerminalSource: "remote_event", acpProcessTerminalOutcome: "exit",
|
||||
acpProcessTerminalExitCode: 137, acpProcessTerminalSignal: "SIGKILL",
|
||||
} }), {}));
|
||||
expect(result.execution).toEqual({
|
||||
acpProcessTerminalSource: "remote_event", acpProcessTerminalOutcome: "exit",
|
||||
acpProcessTerminalExitCode: 137, acpProcessTerminalSignal: "SIGKILL",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["private source", "", "PROXY_CLOSE", ["proxy_close"], 1, null])(
|
||||
"omits every process terminal field when the source is not a closed label (%j)", (value) => {
|
||||
const result = collectRunFailureDiagnostics(run({ resultJson: {
|
||||
acpProcessTerminalSource: value, acpProcessTerminalOutcome: "exit",
|
||||
acpProcessTerminalExitCode: 1, acpProcessTerminalSignal: "SIGTERM",
|
||||
} }), {});
|
||||
expect(result.execution).toEqual({});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([-1, 256, 1.5, NaN, "1", "private", "SIGPRIVATE", "sigkill", null])(
|
||||
"omits invalid process terminal details (%j)", (value) => {
|
||||
const result = collectRunFailureDiagnostics(run({ resultJson: {
|
||||
acpProcessTerminalSource: "proxy_close", acpProcessTerminalOutcome: value,
|
||||
acpProcessTerminalExitCode: value, acpProcessTerminalSignal: value,
|
||||
} }), {});
|
||||
expect(result.execution).toEqual({ acpProcessTerminalSource: "proxy_close" });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["execute,private command", "", "execute,", "Execute", ["execute"], 1])(
|
||||
"omits pending tool kinds that are not closed labels (%j)", (value) => {
|
||||
const result = collectRunFailureDiagnostics(run({ resultJson: { acpPendingToolKinds: value } }), {});
|
||||
|
||||
@@ -6,7 +6,11 @@ import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js";
|
||||
import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared";
|
||||
import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction";
|
||||
import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics";
|
||||
import { ACP_TOOL_KINDS } from "@paperclipai/adapter-utils/acpx-engine/constants";
|
||||
import {
|
||||
ACP_PROCESS_TERMINAL_SIGNALS,
|
||||
ACP_PROCESS_TERMINAL_SOURCES,
|
||||
ACP_TOOL_KINDS,
|
||||
} from "@paperclipai/adapter-utils/acpx-engine/constants";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
|
||||
import { readNativeModelRejectionDiagnostic, readNativeTurnFailureDiagnostic } from "./native-runtime/native-provider-failure.js";
|
||||
@@ -62,6 +66,22 @@ function readAcpToolKind(value: unknown): string | null {
|
||||
return typeof value === "string" && ACP_PENDING_TOOL_KIND_LABELS.has(value) ? value : null;
|
||||
}
|
||||
|
||||
/** How the sandbox agent process session ended, as closed labels and a bounded exit code. */
|
||||
function readAcpProcessTerminal(result: unknown): Context {
|
||||
const source = ACP_PROCESS_TERMINAL_SOURCES.find((label) => label === read(result, "acpProcessTerminalSource"));
|
||||
if (!source) return {};
|
||||
const fields: Context = { acpProcessTerminalSource: source };
|
||||
const outcome = read(result, "acpProcessTerminalOutcome");
|
||||
if (outcome === "exit" || outcome === "error") fields.acpProcessTerminalOutcome = outcome;
|
||||
const exitCode = read(result, "acpProcessTerminalExitCode");
|
||||
if (typeof exitCode === "number" && Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255) {
|
||||
fields.acpProcessTerminalExitCode = exitCode;
|
||||
}
|
||||
const signal = ACP_PROCESS_TERMINAL_SIGNALS.find((label) => label === read(result, "acpProcessTerminalSignal"));
|
||||
if (signal) fields.acpProcessTerminalSignal = signal;
|
||||
return fields;
|
||||
}
|
||||
|
||||
function read(value: unknown, key: string): unknown {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
try {
|
||||
@@ -322,6 +342,7 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport
|
||||
}
|
||||
const closeSettlement = readNativeCloseSettlement(options.nativeCloseFailure);
|
||||
if (closeSettlement) Object.assign(execution, closeSettlement);
|
||||
Object.assign(execution, readAcpProcessTerminal(result));
|
||||
const pendingToolKinds = readAcpPendingToolKinds(read(result, "acpPendingToolKinds"));
|
||||
if (pendingToolKinds) execution.acpPendingToolKinds = pendingToolKinds;
|
||||
const stalledToolKind = readAcpToolKind(read(result, "acpStalledToolKind"));
|
||||
|
||||
Reference in new issue
Block a user