diff --git a/doc/observability.md b/doc/observability.md index 470d0413ef..08dff65ea7 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -475,6 +475,15 @@ pending. Recent events do not prove useful progress. These fields contain only numbers and a boolean, never tool names, IDs, arguments, or event content, and do not change the execution timeout, cancellation, or recovery policy. +ACP 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 +`configured`, `sandbox_default`, or `unlimited`; `timeoutConfigured` identifies an +explicit override, including a negative value that disables the timer. An untouched +sandbox value of zero reports the four-hour default, while a local zero reports +unlimited. Older adapters without a valid resolution retain the config-based +metadata fallback. This does not change timers, Stop acknowledgement, or recovery. + When settlement records a workspace restore failure, `run_execution` also includes `workspaceRestoreFailure` with one of the shared, path-free codes: `restore_permission_denied`, `restore_lock_timeout`, `restore_unsafe_archive`, diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 61f4c4701c..ffc724b4d9 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -2974,6 +2974,9 @@ describe("gemini ACP flag selection", () => { expect(explicitZero.runtimeOptions[0]?.timeoutMs).toBe( DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000, ); + expect(explicitZero.result.resultJson?.adapterExecutionTimeout).toEqual({ + timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default", + }); // A negative timeoutSec is the documented opt-out from any adapter // wall-clock timeout, sandbox targets included. @@ -2982,6 +2985,7 @@ describe("gemini ACP flag selection", () => { sandboxContext, ); expect(negativeOptOut.runtimeOptions[0]?.timeoutMs).toBeUndefined(); + expect(negativeOptOut.result.resultJson?.adapterExecutionTimeout).toEqual({ timeoutSec: 0, source: "configured" }); const startLine = negativeOptOut.logs.find( (entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"), ); @@ -3047,6 +3051,7 @@ describe("gemini ACP flag selection", () => { "Set adapterConfig.timeoutSec to raise it."; expect(result.timedOut).toBe(true); expect(result.errorCode).toBe("acpx_timeout"); + expect(result.resultJson?.adapterExecutionTimeout).toEqual({ timeoutSec: 1, source: "configured" }); expect(result.errorMessage).toBe(expectedMessage); expect(cancelReasons).toContain(expectedMessage); expect(result.resultJson).toMatchObject({ acpObservedEventCount: 0, acpPendingToolCount: 0, acpToolInventoryComplete: true }); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index a837997de5..a40d3a36f3 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -5483,6 +5483,15 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { } }, }; } + // Preserve the policy resolved for this execution target. The server + // cannot reconstruct sandbox defaults from the stored agent config. + capturedResult = { + ...capturedResult, + resultJson: { + ...capturedResult.resultJson, + adapterExecutionTimeout: { ...prepared.timeoutResolution }, + }, + }; // The sync-back settlement step runs before this reproduces the result // (settlement precedes reproduction), so a failed workspace restore is // already recorded by the time we get here. Merge it into `resultJson` diff --git a/server/src/services/heartbeat-stop-metadata.test.ts b/server/src/services/heartbeat-stop-metadata.test.ts index 92624ea202..301a877063 100644 --- a/server/src/services/heartbeat-stop-metadata.test.ts +++ b/server/src/services/heartbeat-stop-metadata.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { resolveAdapterExecutionTargetTimeout } from "@paperclipai/adapter-utils/execution-target"; import { buildHeartbeatRunStopMetadata, mergeHeartbeatRunStopMetadata, @@ -42,6 +43,50 @@ describe("heartbeat stop metadata", () => { }); }); + it.each([ + { target: "sandbox", configured: undefined, seconds: 14400, source: "sandbox_default", explicit: false }, + { target: "sandbox", configured: 0, seconds: 14400, source: "sandbox_default", explicit: false }, + { target: "sandbox", configured: 90, seconds: 90, source: "configured", explicit: true }, + { target: "sandbox", configured: 0.5, seconds: 0.5, source: "configured", explicit: true }, + { target: "sandbox", configured: -1, seconds: 0, source: "configured", explicit: true }, + { target: "local", configured: 0, seconds: 0, source: "unlimited", explicit: false }, + ])("retains the resolved $target timeout for config $configured", ({ target, configured, seconds, source, explicit }) => { + const adapterExecutionTimeout = resolveAdapterExecutionTargetTimeout( + target === "sandbox" ? { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } : { kind: "local" }, + configured, + ); + const result = mergeHeartbeatRunStopMetadata( + { adapterExecutionTimeout, summary: "retained", executionCancellation: { state: "unconfirmed" } }, + buildHeartbeatRunStopMetadata({ + adapterType: "claude_local", adapterConfig: { timeoutSec: configured }, outcome: "timed_out", + }), + ); + expect(result).toMatchObject({ + effectiveTimeoutSec: seconds, timeoutSource: source, timeoutConfigured: explicit, + stopReason: "timeout", timeoutFired: true, summary: "retained", + executionCancellation: { state: "unconfirmed" }, + }); + }); + + it.each([ + null, [], { timeoutSec: 90 }, { timeoutSec: "90", source: "configured" }, + { timeoutSec: -1, source: "configured" }, { timeoutSec: Infinity, source: "configured" }, + { timeoutSec: 90, source: "unknown" }, { timeoutSec: 90, source: "unlimited" }, + { timeoutSec: 0, source: "sandbox_default" }, + ])("falls back to config for an invalid adapter resolution: %j", (adapterExecutionTimeout) => { + expect(mergeHeartbeatRunStopMetadata( + { adapterExecutionTimeout }, + buildHeartbeatRunStopMetadata({ adapterType: "claude_local", adapterConfig: { timeoutSec: 45 }, outcome: "failed" }), + )).toMatchObject({ effectiveTimeoutSec: 45, timeoutConfigured: true, timeoutSource: "config", timeoutFired: false }); + }); + + it("keeps the HTTP millisecond policy internally consistent", () => { + expect(mergeHeartbeatRunStopMetadata( + { adapterExecutionTimeout: { timeoutSec: 90, source: "configured" } }, + buildHeartbeatRunStopMetadata({ adapterType: "http", adapterConfig: { timeoutMs: 2500 }, outcome: "failed" }), + )).toMatchObject({ effectiveTimeoutSec: 2.5, effectiveTimeoutMs: 2500, timeoutSource: "config", timeoutFired: false }); + }); + it("distinguishes budget cancellation from manual cancellation", () => { expect( buildHeartbeatRunStopMetadata({ diff --git a/server/src/services/heartbeat-stop-metadata.ts b/server/src/services/heartbeat-stop-metadata.ts index ef89175c29..5bbb3337ea 100644 --- a/server/src/services/heartbeat-stop-metadata.ts +++ b/server/src/services/heartbeat-stop-metadata.ts @@ -16,7 +16,7 @@ export interface HeartbeatRunTimeoutPolicy { effectiveTimeoutSec: number | null; effectiveTimeoutMs?: number | null; timeoutConfigured: boolean; - timeoutSource: "config" | "default" | "unknown"; + timeoutSource: "config" | "default" | "unknown" | "configured" | "sandbox_default" | "unlimited"; } export interface HeartbeatRunStopMetadata extends HeartbeatRunTimeoutPolicy { @@ -121,12 +121,28 @@ export function mergeHeartbeatRunStopMetadata( metadata: HeartbeatRunStopMetadata, ): Record { const existingMaxTurnStopReason = normalizeMaxTurnStopReason(resultJson?.stopReason); + // Only a complete, valid adapter resolution overrides the config fallback. + // Older adapters and persisted rows retain their existing interpretation. + const resolution = resultJson?.adapterExecutionTimeout; + let timeoutPolicy: HeartbeatRunTimeoutPolicy = metadata; + if (metadata.effectiveTimeoutMs == null && resolution && typeof resolution === "object" && !Array.isArray(resolution)) { + const { timeoutSec, source } = resolution as Record; + if (typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec >= 0 && + (source === "configured" || (source === "sandbox_default" && timeoutSec > 0) || + (source === "unlimited" && timeoutSec === 0))) { + timeoutPolicy = { + effectiveTimeoutSec: timeoutSec, + timeoutConfigured: source === "configured", + timeoutSource: source, + }; + } + } return { ...(resultJson ?? {}), stopReason: existingMaxTurnStopReason ?? metadata.stopReason, - effectiveTimeoutSec: metadata.effectiveTimeoutSec, - timeoutConfigured: metadata.timeoutConfigured, - timeoutSource: metadata.timeoutSource, + effectiveTimeoutSec: timeoutPolicy.effectiveTimeoutSec, + timeoutConfigured: timeoutPolicy.timeoutConfigured, + timeoutSource: timeoutPolicy.timeoutSource, timeoutFired: metadata.timeoutFired, ...(metadata.effectiveTimeoutMs != null ? { effectiveTimeoutMs: metadata.effectiveTimeoutMs } : {}), };