mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
## 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>
178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { resolveAdapterExecutionTargetTimeout } from "@paperclipai/adapter-utils/execution-target";
|
|
import {
|
|
buildHeartbeatRunStopMetadata,
|
|
mergeHeartbeatRunStopMetadata,
|
|
resolveHeartbeatRunTimeoutPolicy,
|
|
} from "./heartbeat-stop-metadata.js";
|
|
|
|
describe("heartbeat stop metadata", () => {
|
|
it("keeps local coding adapters at no timeout by default", () => {
|
|
for (const adapterType of [
|
|
"codex_local",
|
|
"claude_local",
|
|
"cursor",
|
|
"gemini_local",
|
|
"opencode_local",
|
|
"pi_local",
|
|
"process",
|
|
]) {
|
|
expect(resolveHeartbeatRunTimeoutPolicy(adapterType, {})).toEqual({
|
|
effectiveTimeoutSec: 0,
|
|
timeoutConfigured: false,
|
|
timeoutSource: "default",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("records configured timeout policy and timeout stop reason", () => {
|
|
const metadata = buildHeartbeatRunStopMetadata({
|
|
adapterType: "codex_local",
|
|
adapterConfig: { timeoutSec: 45 },
|
|
outcome: "timed_out",
|
|
errorCode: "timeout",
|
|
errorMessage: "Timed out after 45s",
|
|
});
|
|
|
|
expect(metadata).toEqual({
|
|
effectiveTimeoutSec: 45,
|
|
timeoutConfigured: true,
|
|
timeoutSource: "config",
|
|
stopReason: "timeout",
|
|
timeoutFired: true,
|
|
});
|
|
});
|
|
|
|
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({
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
outcome: "cancelled",
|
|
errorCode: "cancelled",
|
|
errorMessage: "Cancelled due to budget pause",
|
|
}).stopReason,
|
|
).toBe("budget_paused");
|
|
|
|
expect(
|
|
buildHeartbeatRunStopMetadata({
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
outcome: "cancelled",
|
|
errorCode: "cancelled",
|
|
errorMessage: "Cancelled by control plane",
|
|
}).stopReason,
|
|
).toBe("cancelled");
|
|
});
|
|
|
|
it("records graceful interruption separately from failure", () => {
|
|
expect(
|
|
buildHeartbeatRunStopMetadata({
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
outcome: "interrupted",
|
|
errorCode: "server_shutdown_interrupted",
|
|
errorMessage: "Interrupted by graceful server shutdown",
|
|
}).stopReason,
|
|
).toBe("interrupted");
|
|
});
|
|
|
|
it("normalizes max-turn exhaustion stop reasons", () => {
|
|
expect(
|
|
buildHeartbeatRunStopMetadata({
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
outcome: "failed",
|
|
errorCode: "turn_limit_exhausted",
|
|
errorMessage: "turn limit reached",
|
|
}).stopReason,
|
|
).toBe("max_turns_exhausted");
|
|
|
|
const merged = mergeHeartbeatRunStopMetadata(
|
|
{ stopReason: "turn_limit_exhausted" },
|
|
buildHeartbeatRunStopMetadata({
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
outcome: "failed",
|
|
errorCode: "adapter_failed",
|
|
}),
|
|
);
|
|
expect(merged.stopReason).toBe("max_turns_exhausted");
|
|
});
|
|
|
|
it("prioritizes succeeded outcome over inconsistent max-turn error metadata", () => {
|
|
expect(
|
|
buildHeartbeatRunStopMetadata({
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
outcome: "succeeded",
|
|
errorCode: "max_turns_exhausted",
|
|
}).stopReason,
|
|
).toBe("completed");
|
|
});
|
|
|
|
it("preserves existing result fields when merging stop metadata", () => {
|
|
const result = mergeHeartbeatRunStopMetadata(
|
|
{ summary: "done" },
|
|
buildHeartbeatRunStopMetadata({
|
|
adapterType: "openclaw_gateway",
|
|
adapterConfig: {},
|
|
outcome: "succeeded",
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
summary: "done",
|
|
stopReason: "completed",
|
|
effectiveTimeoutSec: 120,
|
|
timeoutConfigured: true,
|
|
timeoutSource: "default",
|
|
timeoutFired: false,
|
|
});
|
|
});
|
|
});
|