fix: retain resolved ACP execution timeout metadata (#14986)

## Thinking Path

> - Paperclip manages agent work through adapters and heartbeat runs.
> - ACP adapters resolve a timeout for the selected execution target.
> - An untouched sandbox timeout uses a four-hour default.
> - Heartbeat finalization rebuilt the metadata from the stored zero.
> - This made a timed-out sandbox run report an effective timeout of
zero.
> - This change retains the adapter's resolved policy for accurate run
diagnostics.

## Linked Issues or Issue Description

**What happened?**

ACP sandbox runs with `timeoutSec: 0` use the four-hour default. Their
terminal metadata reports `effectiveTimeoutSec: 0` and `timeoutSource:
config` because heartbeat finalization only reads the stored agent
configuration.

**Expected behavior**

The result must report the policy the adapter used: `14400` and
`sandbox_default`. Explicit limits, fractional limits, explicit
unlimited overrides, and local defaults must keep their resolved values.

**Steps to reproduce**

Run an ACP adapter on a sandbox target with `timeoutSec: 0`. Compare the
start log's four-hour policy with the terminal result's effective
timeout. The new tests exercise the adapter result and the heartbeat
metadata merge without waiting four hours.

**Paperclip version or commit**

Reproduced from `6eaf218924f0a89faf1c02eb0d6877a6c5c8a2cb`.

**Deployment mode**

Self-hosted server with an ACP sandbox execution target.

Searched open timeout and metadata issues and PRs. Related #14804
exposes timeout configuration in forms; #14496 proposes a default
policy; #14833 addresses CLI session retention. This PR changes only ACP
result metadata.

## What Changed

- Retain the resolved timeout in the ACP result after settlement.
- Use validated adapter resolution when merging terminal timeout
metadata. Preserve config fallbacks for older adapters and the HTTP
millisecond policy.
- Test sandbox defaults, explicit and fractional limits, explicit
unlimited overrides, local defaults, malformed metadata, and unchanged
cancellation fields.
- Document the result fields and their meaning.

## Verification

- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- Stop metadata tests: 23 passed.
- Reporter and diagnostic suites: 84 passed; two real-Sentry-SDK tests
skipped because the optional SDK is not installed.
- ACP engine suite: all 206 tests passed with a deterministic local
`gemini --version` shim. Ambient host CLI probes made the existing
Gemini session-resume fixture intermittent (one assertion failure in
each of two broad runs); an isolated 15-case rerun and the clean-base
206-test suite also passed. No assertion or timeout was changed.
- `pnpm test:run` is in progress. This PR does not claim a complete
local suite pass.
- Independent review found no blocking issues. The tests cover real
adapter emission and the real metadata merge separately.

## Risks

Low runtime risk: this changes result diagnostics. It does not change
timeout values, cancellation acknowledgement, cleanup, checkpoint
safety, retries, or provider operations. It does not fix the cause of a
quiet or long-running tool. Older stored results are not rewritten. The
new source values apply only when an adapter returns a valid resolution.

## Model Used

OpenAI GPT-6 with reasoning, repository inspection, code editing, and
test execution. The deployment-specific model ID and context-window size
are not exposed in this session.

## 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
- [ ] 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
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-02 16:09:36 -07:00
1 parent 862a5758ba
commit 4abff286c2
5 files changed
+88 -4

No files matched your search

+9
View File
@@ -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`,
@@ -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 });
@@ -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`
@@ -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({
+20 -4
View File
@@ -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<string, unknown> {
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<string, unknown>;
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 } : {}),
};