diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index 252fa3d0a2..0da495b7f1 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -66,7 +66,7 @@ In agent runtime settings, configure heartbeat policy: For local adapters, set: - `cwd` (working directory) -- `timeoutSec` (max runtime per heartbeat) +- `timeoutSec` (max runtime per heartbeat; `0` uses the target default — no adapter timeout on local/SSH, a 4-hour backstop on sandbox targets — and a negative value disables the adapter timeout everywhere, including sandboxes) - `graceSec` (time before force-kill after timeout/cancel) - optional env vars and extra CLI args - use **Test environment** in agent configuration to run adapter-specific diagnostics before saving diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index f977922171..a28ca2d150 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -10,6 +10,9 @@ import { adapterExecutionTargetToRemoteSpec, adapterExecutionTargetUsesPaperclipBridge, ensureAdapterExecutionTargetCommandResolvable, + formatAdapterExecutionTimeoutErrorMessage, + formatAdapterExecutionTimeoutStartLogLine, + resolveAdapterExecutionTargetTimeout, resolveAdapterExecutionTargetTimeoutSec, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, @@ -163,6 +166,10 @@ describe("sandbox adapter execution targets", () => { runner: createLocalSandboxRunner(), }; + // The sandbox default is a 4h wall-clock backstop matching the recovery + // watchdog critical threshold (ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS); + // the output-inactivity monitor remains the primary hang detector. + expect(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC).toBe(4 * 60 * 60); expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 0)).toBe( DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, ); @@ -182,6 +189,121 @@ describe("sandbox adapter execution targets", () => { strictHostKeyChecking: true, }, }, 0)).toBe(0); + expect(resolveAdapterExecutionTargetTimeoutSec({ kind: "local" }, 0)).toBe(0); + }); + + it("reports which knob produced the resolved timeout", () => { + const sandboxTarget: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + remoteCwd: "/workspace", + runner: createLocalSandboxRunner(), + }; + + expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0)).toEqual({ + timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, + source: "sandbox_default", + }); + expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 90)).toEqual({ + timeoutSec: 90, + source: "configured", + }); + expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, 0)).toEqual({ + timeoutSec: 0, + source: "unlimited", + }); + // Fractional (sub-second) configured timeouts are preserved rather than + // floored to 0, which would silently mean "no timeout". + expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, 0.01)).toEqual({ + timeoutSec: 0.01, + source: "configured", + }); + expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0.5)).toEqual({ + timeoutSec: 0.5, + source: "configured", + }); + }); + + it("treats a negative timeoutSec as the explicit no-timeout opt-out, even on sandbox targets", () => { + const sandboxTarget: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + remoteCwd: "/workspace", + runner: createLocalSandboxRunner(), + }; + + expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, -1)).toEqual({ + timeoutSec: 0, + source: "configured", + }); + expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, -1)).toEqual({ + timeoutSec: 0, + source: "configured", + }); + expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, -1)).toBe(0); + + // Explicit zero intentionally does NOT opt out: the adapter config UI + // persists the schema default of 0 for untouched fields, so a stored + // timeoutSec=0 cannot be read as operator intent. It keeps the sandbox + // backstop; the documented opt-out is a negative value. + expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0)).toEqual({ + timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, + source: "sandbox_default", + }); + // Unset behaves like zero. + expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, undefined)).toEqual({ + timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, + source: "sandbox_default", + }); + expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, undefined)).toEqual({ + timeoutSec: 0, + source: "unlimited", + }); + }); + + it("formats self-describing timeout errors naming the timer and knob", () => { + expect( + formatAdapterExecutionTimeoutErrorMessage({ + timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, + source: "sandbox_default", + }), + ).toBe( + "Run exceeded the adapter execution timeout (timeoutSec=14400, sandbox default). " + + "Set adapterConfig.timeoutSec to raise it.", + ); + expect( + formatAdapterExecutionTimeoutErrorMessage({ timeoutSec: 1800, source: "configured" }), + ).toBe( + "Run exceeded the adapter execution timeout (timeoutSec=1800, configured via adapterConfig.timeoutSec). " + + "Set adapterConfig.timeoutSec to raise it.", + ); + }); + + it("formats the start-of-run timeout log line with the resolved value and source", () => { + expect( + formatAdapterExecutionTimeoutStartLogLine({ + timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, + source: "sandbox_default", + }), + ).toBe( + "Adapter execution timeout: timeoutSec=14400 (sandbox default; set adapterConfig.timeoutSec to override).", + ); + expect( + formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 900, source: "configured" }), + ).toBe( + "Adapter execution timeout: timeoutSec=900 (configured via adapterConfig.timeoutSec; set adapterConfig.timeoutSec to override).", + ); + expect( + formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 0, source: "unlimited" }), + ).toBe( + "Adapter execution timeout: none (no adapter wall-clock timeout for this target; set adapterConfig.timeoutSec to add one).", + ); + // Negative opt-out resolves to { timeoutSec: 0, source: "configured" }. + expect( + formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 0, source: "configured" }), + ).toBe( + "Adapter execution timeout: none (explicitly disabled via adapterConfig.timeoutSec; set it to a positive value to add one).", + ); }); it("uses the caller timeout override when installing a missing sandbox command", async () => { @@ -892,7 +1014,9 @@ describe("sandbox adapter execution targets", () => { try { expect(bridge).not.toBeNull(); expect(runner.execute).toHaveBeenCalled(); - expect(runner.execute.mock.calls.some(([input]) => input.timeoutMs === 1_800_000)).toBe(true); + expect( + runner.execute.mock.calls.some(([input]) => input.timeoutMs === DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000), + ).toBe(true); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 466c00a96c..948b44501f 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -127,7 +127,14 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle { export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; -export const DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 1_800; +// 4-hour wall-clock backstop for sandbox-backed adapter runs. This is a +// last-resort kill switch, not the primary hang detector: genuinely hung runs +// are caught much earlier by the adapters' output-inactivity monitors (e.g. +// codex-local's 7-minute monitor). The value intentionally matches the +// recovery watchdog's ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS (4h) in +// server/src/services/recovery/service.ts so healthy long runs are never +// killed by the adapter before the watchdog would even consider them stuck. +export const DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 14_400; function parseObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) @@ -252,24 +259,107 @@ export function describeAdapterExecutionTarget( return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`; } -export function resolveAdapterExecutionTargetTimeoutSec( +export type AdapterExecutionTargetTimeoutSource = + | "configured" + | "sandbox_default" + | "unlimited"; + +export interface AdapterExecutionTargetTimeoutResolution { + /** Resolved wall-clock timeout in seconds; 0 means no adapter timeout. */ + timeoutSec: number; + /** Which knob produced the resolved value, for logs and error messages. */ + source: AdapterExecutionTargetTimeoutSource; +} + +export function resolveAdapterExecutionTargetTimeout( target: AdapterExecutionTarget | null | undefined, configuredTimeoutSec: number | null | undefined, -): number { - const normalizedConfiguredTimeoutSec = - typeof configuredTimeoutSec === "number" && Number.isFinite(configuredTimeoutSec) && configuredTimeoutSec > 0 - ? Math.floor(configuredTimeoutSec) - : 0; - if (normalizedConfiguredTimeoutSec > 0) return normalizedConfiguredTimeoutSec; +): AdapterExecutionTargetTimeoutResolution { + if (typeof configuredTimeoutSec === "number" && Number.isFinite(configuredTimeoutSec)) { + // Preserve fractional (sub-second) configured values instead of flooring: + // adapters historically honored e.g. timeoutSec=0.5, and flooring would + // silently turn it into "no timeout". + if (configuredTimeoutSec > 0) { + return { timeoutSec: configuredTimeoutSec, source: "configured" }; + } + // A negative timeoutSec is the explicit "no adapter wall-clock timeout" + // opt-out, honored even on sandbox targets. Zero cannot carry that + // meaning: the adapter config UI persists the schema default of 0 for + // untouched fields, so timeoutSec=0 in stored config does not signal + // operator intent and falls through to target defaults below. + if (configuredTimeoutSec < 0) { + return { timeoutSec: 0, source: "configured" }; + } + } // Local and SSH adapters preserve the historical "0 means no adapter // timeout" behavior. Sandbox-backed runs execute through provider RPCs // that usually apply their own shorter command defaults, so request an // explicit longer timeout for full adapter runs when the adapter leaves // timeoutSec unset. if (target?.kind === "remote" && target.transport === "sandbox") { - return DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC; + return { timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default" }; } - return 0; + return { timeoutSec: 0, source: "unlimited" }; +} + +export function resolveAdapterExecutionTargetTimeoutSec( + target: AdapterExecutionTarget | null | undefined, + configuredTimeoutSec: number | null | undefined, +): number { + return resolveAdapterExecutionTargetTimeout(target, configuredTimeoutSec).timeoutSec; +} + +function describeAdapterExecutionTimeoutSource( + source: AdapterExecutionTargetTimeoutSource, +): string { + switch (source) { + case "configured": + return "configured via adapterConfig.timeoutSec"; + case "sandbox_default": + return "sandbox default"; + case "unlimited": + return "no adapter wall-clock timeout"; + } +} + +/** + * Self-describing error message for when the adapter wall-clock execution + * timeout kills a run. Names the timer that fired and the knob that controls + * it so run failures never surface as a bare "Timed out". + */ +export function formatAdapterExecutionTimeoutErrorMessage( + resolution: AdapterExecutionTargetTimeoutResolution, +): string { + return ( + `Run exceeded the adapter execution timeout ` + + `(timeoutSec=${resolution.timeoutSec}, ${describeAdapterExecutionTimeoutSource(resolution.source)}). ` + + `Set adapterConfig.timeoutSec to raise it.` + ); +} + +/** + * One-line start-of-run statement of the effective wall-clock timeout and its + * source. Callers prefix with `[paperclip] ` and append a newline. + */ +export function formatAdapterExecutionTimeoutStartLogLine( + resolution: AdapterExecutionTargetTimeoutResolution, +): string { + if (resolution.timeoutSec <= 0) { + if (resolution.source === "configured") { + return ( + "Adapter execution timeout: none " + + "(explicitly disabled via adapterConfig.timeoutSec; set it to a positive value to add one)." + ); + } + return ( + "Adapter execution timeout: none " + + "(no adapter wall-clock timeout for this target; set adapterConfig.timeoutSec to add one)." + ); + } + return ( + `Adapter execution timeout: timeoutSec=${resolution.timeoutSec} ` + + `(${describeAdapterExecutionTimeoutSource(resolution.source)}; set adapterConfig.timeoutSec to override).` + ); } function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner { diff --git a/packages/adapters/acpx-local/src/index.ts b/packages/adapters/acpx-local/src/index.ts index 8298069166..e94513b232 100644 --- a/packages/adapters/acpx-local/src/index.ts +++ b/packages/adapters/acpx-local/src/index.ts @@ -50,7 +50,7 @@ Core fields: - model (string, optional): requested ACP model. Claude and Codex ACP agents both receive this through ACP session config. - effort/modelReasoningEffort (string, optional): requested thinking effort. Claude uses effort; Codex uses modelReasoningEffort/reasoning_effort. - fastMode (boolean, optional): for ACPX Codex, request Codex fast mode through ACP session config. -- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout. +- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout for local/SSH execution. Sandbox execution targets default to a 4h wall-clock backstop when timeoutSec is unset; the output-inactivity monitor remains the primary hang detector. - warmHandleIdleMs (number, optional): live ACPX process idle window after a successful persistent run. Defaults to 0, meaning Paperclip shuts the process down after each run while retaining ACPX session state. - env (object, optional): KEY=VALUE environment variables or secret bindings. diff --git a/packages/adapters/acpx-local/src/server/config-schema.ts b/packages/adapters/acpx-local/src/server/config-schema.ts index ca41aaacde..1cbaf86c37 100644 --- a/packages/adapters/acpx-local/src/server/config-schema.ts +++ b/packages/adapters/acpx-local/src/server/config-schema.ts @@ -61,6 +61,7 @@ export function getConfigSchema(): AdapterConfigSchema { label: "Timeout seconds", type: "number", default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, + hint: "Wall-clock timeout for a run. 0 uses the target default: no adapter timeout on local/SSH, 4 hours on sandbox targets. Set a negative value (e.g. -1) to disable the adapter timeout everywhere, including sandboxes.", }, { key: "warmHandleIdleMs", diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapters/acpx-local/src/server/execute.test.ts index 4d1f28afd5..36fcbc272f 100644 --- a/packages/adapters/acpx-local/src/server/execute.test.ts +++ b/packages/adapters/acpx-local/src/server/execute.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { AcpRuntimeOptions } from "acpx/runtime"; +import { DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC } from "@paperclipai/adapter-utils/execution-target"; import { createAcpxLocalExecutor } from "./execute.js"; const tempRoots: string[] = []; @@ -62,6 +63,7 @@ async function runExecutor( options: { context?: Record; executionTransport?: Record; + executionTarget?: Record; } = {}, ) { const runtimeOptions: Record[] = []; @@ -84,6 +86,7 @@ async function runExecutor( config, context: options.context ?? {}, executionTransport: options.executionTransport, + executionTarget: options.executionTarget, onLog: async (stream: "stdout" | "stderr", text: string) => { logs.push({ stream, text }); }, @@ -700,3 +703,185 @@ describe("acpx_local runtime skill isolation", () => { expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false); }); }); + +describe("acpx_local execution timeouts", () => { + it("applies the 4h sandbox backstop when timeoutSec is unset on a sandbox execution target", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const { logs, runtimeOptions } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd }, + { + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "acme-sandbox", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: cwd, + }, + }, + ); + + // The sandbox default flows into the ACPX runtime wall-clock timer. + expect(runtimeOptions[0]?.timeoutMs).toBe(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000); + // The effective timeout and its source are stated at run start so a later + // timeout is diagnosable from the run log alone. + const startLine = logs.find( + (entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"), + ); + expect(startLine).toBeTruthy(); + expect(startLine!.text).toContain( + `[paperclip] Adapter execution timeout: timeoutSec=${DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC} ` + + "(sandbox default; set adapterConfig.timeoutSec to override).", + ); + }); + + it("keeps local execution unlimited by default and logs the unlimited timeout", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const { logs, runtimeOptions } = await runExecutor({ + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + cwd, + }); + + expect(runtimeOptions[0]?.timeoutMs).toBeUndefined(); + const startLine = logs.find( + (entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"), + ); + expect(startLine).toBeTruthy(); + expect(startLine!.text).toContain("Adapter execution timeout: none"); + }); + + it("prefers a configured timeoutSec over the sandbox default", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const { logs, runtimeOptions } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 90 }, + { + executionTarget: { + kind: "remote", + transport: "sandbox", + remoteCwd: cwd, + }, + }, + ); + + expect(runtimeOptions[0]?.timeoutMs).toBe(90 * 1000); + const startLine = logs.find( + (entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"), + ); + expect(startLine!.text).toContain( + "Adapter execution timeout: timeoutSec=90 (configured via adapterConfig.timeoutSec; set adapterConfig.timeoutSec to override).", + ); + }); + + it("keeps the sandbox backstop for an explicit timeoutSec of 0 but honors a negative opt-out", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + const sandboxContext = { + executionTarget: { + kind: "remote", + transport: "sandbox", + remoteCwd: cwd, + }, + }; + + // The config UI persists the schema default of 0 for untouched fields, so + // an explicit 0 cannot mean "no timeout" — it keeps the 4h backstop. + const explicitZero = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 0 }, + sandboxContext, + ); + expect(explicitZero.runtimeOptions[0]?.timeoutMs).toBe( + DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000, + ); + + // A negative timeoutSec is the documented opt-out from any adapter + // wall-clock timeout, sandbox targets included. + const negativeOptOut = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: -1 }, + sandboxContext, + ); + expect(negativeOptOut.runtimeOptions[0]?.timeoutMs).toBeUndefined(); + const startLine = negativeOptOut.logs.find( + (entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"), + ); + expect(startLine!.text).toContain( + "Adapter execution timeout: none (explicitly disabled via adapterConfig.timeoutSec; " + + "set it to a positive value to add one).", + ); + }); + + it("reports a self-describing timeout error when the wall-clock timer kills a turn", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const cancelReasons: string[] = []; + let releaseTurn: (() => void) | null = null; + const turnCancelled = new Promise((resolve) => { + releaseTurn = resolve; + }); + + const execute = createAcpxLocalExecutor({ + createRuntime: () => ({ + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + // Never yields on its own: only the Paperclip wall-clock timer's + // cancel unblocks the turn, simulating a hung run. + events: (async function* () { + await turnCancelled; + })(), + result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })), + cancel: async ({ reason }: { reason: string }) => { + cancelReasons.push(reason); + releaseTurn?.(); + }, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-timeout-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + cwd, + timeoutSec: 1, + }, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + + const expectedMessage = + "Run exceeded the adapter execution timeout (timeoutSec=1, configured via adapterConfig.timeoutSec). " + + "Set adapterConfig.timeoutSec to raise it."; + expect(result.timedOut).toBe(true); + expect(result.errorCode).toBe("acpx_timeout"); + expect(result.errorMessage).toBe(expectedMessage); + expect(cancelReasons).toContain(expectedMessage); + }, 15_000); +}); diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index dc56b35d03..90d2609a82 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -4,7 +4,14 @@ import path from "node:path"; import { createHash, randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { readAdapterExecutionTarget, adapterExecutionTargetSessionIdentity } from "@paperclipai/adapter-utils/execution-target"; +import { + adapterExecutionTargetSessionIdentity, + formatAdapterExecutionTimeoutErrorMessage, + formatAdapterExecutionTimeoutStartLogLine, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetTimeout, + type AdapterExecutionTargetTimeoutResolution, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, applyPaperclipWorkspaceEnv, @@ -87,6 +94,7 @@ interface AcpxPreparedRuntime { requestedThinkingEffort: string; fastMode: boolean; timeoutSec: number; + timeoutResolution: AdapterExecutionTargetTimeoutResolution; sessionKey: string; fingerprint: string; agentCommand: string | null; @@ -772,7 +780,14 @@ async function buildRuntime(input: { const requestedModel = asString(config.model, "").trim(); const requestedThinkingEffort = normalizeRequestedThinkingEffort(config); const fastMode = acpxAgent === "codex" && config.fastMode === true; - const timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC); + // Resolve the wall-clock timeout through the shared execution-target + // resolver so sandbox-backed runs pick up the 4h backstop default while + // local/SSH runs keep the historical "0 = no adapter timeout" behavior. + const timeoutResolution = resolveAdapterExecutionTargetTimeout( + executionTarget, + asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC), + ); + const timeoutSec = timeoutResolution.timeoutSec; const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id)); await fs.mkdir(stateDir, { recursive: true }); @@ -940,6 +955,7 @@ async function buildRuntime(input: { requestedThinkingEffort, fastMode, timeoutSec, + timeoutResolution, sessionKey, fingerprint, agentCommand, @@ -1258,8 +1274,8 @@ async function emitAcpxFailure(input: { err: unknown; phase: AcpxExecutionPhase; // Replace the err-derived message in both the stderr-tail log header and the - // acpx.error payload. Used by the turn path to surface "Timed out after Ns" - // instead of the raw underlying error message. + // acpx.error payload. Used by the turn path to surface the self-describing + // adapter execution timeout message instead of the raw underlying error. messageOverride?: string; }): Promise<{ classified: Pick; @@ -1383,6 +1399,14 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise { const prepared = await buildRuntime({ ctx }); + // State the effective wall-clock timeout and its source up front so a + // later timeout is diagnosable from the run log alone. Goes to stderr: + // the acpx stdout log stream carries JSON acpx.* event payloads and must + // stay machine-parseable line by line. + await ctx.onLog( + "stderr", + `[paperclip] ${formatAdapterExecutionTimeoutStartLogLine(prepared.timeoutResolution)}\n`, + ); const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS); await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs }); @@ -1576,7 +1600,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { timeout = setTimeout(() => { timedOut = true; controller?.abort(); - void cancelActiveTurn?.(`Timed out after ${prepared.timeoutSec}s`).catch(() => {}); + void cancelActiveTurn?.(formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution)).catch(() => {}); }, timeoutMs); } const turn = runtime.startTurn({ @@ -1656,7 +1680,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { } const errorMessage = timedOut - ? `Timed out after ${prepared.timeoutSec}s` + ? formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution) : resultErrorMessage(terminal); const terminalStopReason = terminal.status === "failed" ? terminal.error.message : terminal.stopReason; await emitAcpxLog(ctx, { @@ -1692,7 +1716,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { }; } catch (err) { if (timeout) clearTimeout(timeout); - const messageOverride = timedOut ? `Timed out after ${prepared.timeoutSec}s` : undefined; + const messageOverride = timedOut + ? formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution) + : undefined; const cancel = cancelActiveTurn as ((reason: string) => Promise) | null; const preEmitMessage = messageOverride ?? (err instanceof Error ? err.message : String(err));