mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +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>
150 lines
5.6 KiB
TypeScript
150 lines
5.6 KiB
TypeScript
export type HeartbeatRunOutcome = "succeeded" | "interrupted" | "failed" | "cancelled" | "timed_out";
|
|
|
|
export type HeartbeatRunStopReason =
|
|
| "completed"
|
|
| "interrupted"
|
|
| "timeout"
|
|
| "cancelled"
|
|
| "budget_paused"
|
|
| "paused"
|
|
| "max_turns_exhausted"
|
|
| "process_lost"
|
|
| "unmanaged_background_task_stopped"
|
|
| "adapter_failed";
|
|
|
|
export interface HeartbeatRunTimeoutPolicy {
|
|
effectiveTimeoutSec: number | null;
|
|
effectiveTimeoutMs?: number | null;
|
|
timeoutConfigured: boolean;
|
|
timeoutSource: "config" | "default" | "unknown" | "configured" | "sandbox_default" | "unlimited";
|
|
}
|
|
|
|
export interface HeartbeatRunStopMetadata extends HeartbeatRunTimeoutPolicy {
|
|
stopReason: HeartbeatRunStopReason;
|
|
timeoutFired: boolean;
|
|
}
|
|
|
|
function readFiniteNumber(value: unknown): number | null {
|
|
if (typeof value === "number") {
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
if (typeof value === "string") {
|
|
const parsed = Number(value.trim());
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasOwn(record: Record<string, unknown>, key: string) {
|
|
return Object.prototype.hasOwnProperty.call(record, key);
|
|
}
|
|
|
|
function defaultTimeoutSecForAdapter(adapterType: string) {
|
|
return adapterType === "openclaw_gateway" ? 120 : 0;
|
|
}
|
|
|
|
export function normalizeMaxTurnStopReason(value: unknown): Extract<HeartbeatRunStopReason, "max_turns_exhausted"> | null {
|
|
return value === "max_turns_exhausted" || value === "turn_limit_exhausted"
|
|
? "max_turns_exhausted"
|
|
: null;
|
|
}
|
|
|
|
export function resolveHeartbeatRunTimeoutPolicy(
|
|
adapterType: string,
|
|
adapterConfig: Record<string, unknown> | null | undefined,
|
|
): HeartbeatRunTimeoutPolicy {
|
|
const config = adapterConfig ?? {};
|
|
|
|
if (adapterType === "http") {
|
|
const hasTimeoutMs = hasOwn(config, "timeoutMs");
|
|
const rawTimeoutMs = hasTimeoutMs ? readFiniteNumber(config.timeoutMs) : 0;
|
|
const timeoutMs = Math.max(0, Math.floor(rawTimeoutMs ?? 0));
|
|
return {
|
|
effectiveTimeoutSec: timeoutMs / 1000,
|
|
effectiveTimeoutMs: timeoutMs,
|
|
timeoutConfigured: timeoutMs > 0,
|
|
timeoutSource: hasTimeoutMs ? "config" : "default",
|
|
};
|
|
}
|
|
|
|
const hasTimeoutSec = hasOwn(config, "timeoutSec");
|
|
const defaultTimeoutSec = defaultTimeoutSecForAdapter(adapterType);
|
|
const rawTimeoutSec = hasTimeoutSec ? readFiniteNumber(config.timeoutSec) : defaultTimeoutSec;
|
|
const timeoutSec = Math.max(0, Math.floor(rawTimeoutSec ?? defaultTimeoutSec));
|
|
|
|
return {
|
|
effectiveTimeoutSec: timeoutSec,
|
|
timeoutConfigured: timeoutSec > 0,
|
|
timeoutSource: hasTimeoutSec ? "config" : "default",
|
|
};
|
|
}
|
|
|
|
export function inferHeartbeatRunStopReason(input: {
|
|
outcome: HeartbeatRunOutcome;
|
|
errorCode?: string | null;
|
|
errorMessage?: string | null;
|
|
}): HeartbeatRunStopReason {
|
|
if (input.outcome === "succeeded") return "completed";
|
|
if (input.outcome === "interrupted") return "interrupted";
|
|
const maxTurnStopReason = normalizeMaxTurnStopReason(input.errorCode);
|
|
if (maxTurnStopReason) return maxTurnStopReason;
|
|
if (input.outcome === "timed_out") return "timeout";
|
|
if (input.outcome === "failed" && input.errorCode === "unmanaged_background_task_stopped") return "unmanaged_background_task_stopped";
|
|
if (input.outcome === "failed" && input.errorCode === "process_lost") return "process_lost";
|
|
if (input.outcome === "cancelled") {
|
|
const message = (input.errorMessage ?? "").toLowerCase();
|
|
if (message.includes("budget")) return "budget_paused";
|
|
if (message.includes("pause") || message.includes("paused")) return "paused";
|
|
return "cancelled";
|
|
}
|
|
return "adapter_failed";
|
|
}
|
|
|
|
export function buildHeartbeatRunStopMetadata(input: {
|
|
adapterType: string;
|
|
adapterConfig: Record<string, unknown> | null | undefined;
|
|
outcome: HeartbeatRunOutcome;
|
|
errorCode?: string | null;
|
|
errorMessage?: string | null;
|
|
}): HeartbeatRunStopMetadata {
|
|
const timeoutPolicy = resolveHeartbeatRunTimeoutPolicy(input.adapterType, input.adapterConfig);
|
|
const stopReason = inferHeartbeatRunStopReason(input);
|
|
return {
|
|
...timeoutPolicy,
|
|
stopReason,
|
|
timeoutFired: stopReason === "timeout",
|
|
};
|
|
}
|
|
|
|
export function mergeHeartbeatRunStopMetadata(
|
|
resultJson: Record<string, unknown> | null | undefined,
|
|
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: timeoutPolicy.effectiveTimeoutSec,
|
|
timeoutConfigured: timeoutPolicy.timeoutConfigured,
|
|
timeoutSource: timeoutPolicy.timeoutSource,
|
|
timeoutFired: metadata.timeoutFired,
|
|
...(metadata.effectiveTimeoutMs != null ? { effectiveTimeoutMs: metadata.effectiveTimeoutMs } : {}),
|
|
};
|
|
}
|