Skip to content

Commit f77a684

Browse files
asyncjasonJason Separovicclaudeobviyus
authored
feat: make compaction timeout configurable via agents.defaults.compaction.timeoutSeconds (openclaw#46889)
* feat: make compaction timeout configurable via agents.defaults.compaction.timeoutSeconds The hardcoded 5-minute (300s) compaction timeout causes large sessions to enter a death spiral where compaction repeatedly fails and the session grows indefinitely. This adds agents.defaults.compaction.timeoutSeconds to allow operators to override the compaction safety timeout. Default raised to 900s (15min) which is sufficient for sessions up to ~400k tokens. The resolved timeout is also used for the session write lock duration so locks don't expire before compaction completes. Fixes openclaw#38233 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test: add resolveCompactionTimeoutMs tests Cover config resolution edge cases: undefined config, missing compaction section, valid seconds, fractional values, zero, negative, NaN, and Infinity. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: add timeoutSeconds to compaction Zod schema The compaction object schema uses .strict(), so setting the new timeoutSeconds config option would fail validation at startup. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: enforce integer constraint on compaction timeoutSeconds schema Prevents sub-second values like 0.5 which would floor to 0ms and cause immediate compaction timeout. Matches pattern of other integer timeout fields in the schema. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: clamp compaction timeout to Node timer-safe maximum Values above ~2.1B ms overflow Node's setTimeout to 1ms, causing immediate timeout. Clamp to MAX_SAFE_TIMEOUT_MS matching the pattern in agents/timeout.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: add FIELD_LABELS entry for compaction timeoutSeconds Maintains label/help parity invariant enforced by schema.help.quality.test.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: align compaction timeouts with abort handling * fix: land compaction timeout handling (openclaw#46889) (thanks @asyncjason) --------- Co-authored-by: Jason Separovic <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent 8e04d1f commit f77a684

13 files changed

+330
-32
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
2828
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
2929
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. Thanks @vincentkoc.
30+
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
3031

3132
### Fixes
3233

src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
33
compactWithSafetyTimeout,
44
EMBEDDED_COMPACTION_TIMEOUT_MS,
5+
resolveCompactionTimeoutMs,
56
} from "./pi-embedded-runner/compaction-safety-timeout.js";
67

78
describe("compactWithSafetyTimeout", () => {
@@ -42,4 +43,99 @@ describe("compactWithSafetyTimeout", () => {
4243
).rejects.toBe(error);
4344
expect(vi.getTimerCount()).toBe(0);
4445
});
46+
47+
it("calls onCancel when compaction times out", async () => {
48+
vi.useFakeTimers();
49+
const onCancel = vi.fn();
50+
51+
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 30, {
52+
onCancel,
53+
});
54+
const timeoutAssertion = expect(compactPromise).rejects.toThrow("Compaction timed out");
55+
56+
await vi.advanceTimersByTimeAsync(30);
57+
await timeoutAssertion;
58+
expect(onCancel).toHaveBeenCalledTimes(1);
59+
expect(vi.getTimerCount()).toBe(0);
60+
});
61+
62+
it("aborts early on external abort signal and calls onCancel once", async () => {
63+
vi.useFakeTimers();
64+
const controller = new AbortController();
65+
const onCancel = vi.fn();
66+
const reason = new Error("request timed out");
67+
68+
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 100, {
69+
abortSignal: controller.signal,
70+
onCancel,
71+
});
72+
const abortAssertion = expect(compactPromise).rejects.toBe(reason);
73+
74+
controller.abort(reason);
75+
await abortAssertion;
76+
expect(onCancel).toHaveBeenCalledTimes(1);
77+
expect(vi.getTimerCount()).toBe(0);
78+
});
79+
});
80+
81+
describe("resolveCompactionTimeoutMs", () => {
82+
it("returns default when config is undefined", () => {
83+
expect(resolveCompactionTimeoutMs(undefined)).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
84+
});
85+
86+
it("returns default when compaction config is missing", () => {
87+
expect(resolveCompactionTimeoutMs({ agents: { defaults: {} } })).toBe(
88+
EMBEDDED_COMPACTION_TIMEOUT_MS,
89+
);
90+
});
91+
92+
it("returns default when timeoutSeconds is not set", () => {
93+
expect(
94+
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { mode: "safeguard" } } } }),
95+
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
96+
});
97+
98+
it("converts timeoutSeconds to milliseconds", () => {
99+
expect(
100+
resolveCompactionTimeoutMs({
101+
agents: { defaults: { compaction: { timeoutSeconds: 1800 } } },
102+
}),
103+
).toBe(1_800_000);
104+
});
105+
106+
it("floors fractional seconds", () => {
107+
expect(
108+
resolveCompactionTimeoutMs({
109+
agents: { defaults: { compaction: { timeoutSeconds: 120.7 } } },
110+
}),
111+
).toBe(120_000);
112+
});
113+
114+
it("returns default for zero", () => {
115+
expect(
116+
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { timeoutSeconds: 0 } } } }),
117+
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
118+
});
119+
120+
it("returns default for negative values", () => {
121+
expect(
122+
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { timeoutSeconds: -5 } } } }),
123+
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
124+
});
125+
126+
it("returns default for NaN", () => {
127+
expect(
128+
resolveCompactionTimeoutMs({
129+
agents: { defaults: { compaction: { timeoutSeconds: NaN } } },
130+
}),
131+
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
132+
});
133+
134+
it("returns default for Infinity", () => {
135+
expect(
136+
resolveCompactionTimeoutMs({
137+
agents: { defaults: { compaction: { timeoutSeconds: Infinity } } },
138+
}),
139+
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
140+
});
45141
});

src/agents/pi-embedded-runner/compact.hooks.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
resolveMemorySearchConfigMock,
1515
resolveSessionAgentIdMock,
1616
estimateTokensMock,
17+
sessionAbortCompactionMock,
1718
} = vi.hoisted(() => {
1819
const contextEngineCompactMock = vi.fn(async () => ({
1920
ok: true as boolean,
@@ -65,6 +66,7 @@ const {
6566
})),
6667
resolveSessionAgentIdMock: vi.fn(() => "main"),
6768
estimateTokensMock: vi.fn((_message?: unknown) => 10),
69+
sessionAbortCompactionMock: vi.fn(),
6870
};
6971
});
7072

@@ -121,6 +123,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => {
121123
session.messages.splice(1);
122124
return await sessionCompactImpl();
123125
}),
126+
abortCompaction: sessionAbortCompactionMock,
124127
dispose: vi.fn(),
125128
};
126129
return { session };
@@ -151,6 +154,7 @@ vi.mock("../models-config.js", () => ({
151154
}));
152155

153156
vi.mock("../model-auth.js", () => ({
157+
applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model),
154158
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })),
155159
resolveModelAuthMode: vi.fn(() => "env"),
156160
}));
@@ -420,6 +424,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
420424
resolveSessionAgentIdMock.mockReturnValue("main");
421425
estimateTokensMock.mockReset();
422426
estimateTokensMock.mockReturnValue(10);
427+
sessionAbortCompactionMock.mockReset();
423428
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
424429
});
425430

@@ -772,6 +777,24 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
772777

773778
expect(result.ok).toBe(true);
774779
});
780+
781+
it("aborts in-flight compaction when the caller abort signal fires", async () => {
782+
const controller = new AbortController();
783+
sessionCompactImpl.mockImplementationOnce(() => new Promise<never>(() => {}));
784+
785+
const resultPromise = compactEmbeddedPiSessionDirect(
786+
directCompactionArgs({
787+
abortSignal: controller.signal,
788+
}),
789+
);
790+
791+
controller.abort(new Error("request timed out"));
792+
const result = await resultPromise;
793+
794+
expect(result.ok).toBe(false);
795+
expect(result.reason).toContain("request timed out");
796+
expect(sessionAbortCompactionMock).toHaveBeenCalledTimes(1);
797+
});
775798
});
776799

777800
describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {

src/agents/pi-embedded-runner/compact.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import {
7676
import { resolveTranscriptPolicy } from "../transcript-policy.js";
7777
import {
7878
compactWithSafetyTimeout,
79-
EMBEDDED_COMPACTION_TIMEOUT_MS,
79+
resolveCompactionTimeoutMs,
8080
} from "./compaction-safety-timeout.js";
8181
import { buildEmbeddedExtensionFactories } from "./extensions.js";
8282
import {
@@ -143,6 +143,7 @@ export type CompactEmbeddedPiSessionParams = {
143143
enqueue?: typeof enqueueCommand;
144144
extraSystemPrompt?: string;
145145
ownerNumbers?: string[];
146+
abortSignal?: AbortSignal;
146147
};
147148

148149
type CompactionMessageMetrics = {
@@ -687,10 +688,11 @@ export async function compactEmbeddedPiSessionDirect(
687688
});
688689
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
689690

691+
const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config);
690692
const sessionLock = await acquireSessionWriteLock({
691693
sessionFile: params.sessionFile,
692694
maxHoldMs: resolveSessionLockMaxHoldFromTimeout({
693-
timeoutMs: EMBEDDED_COMPACTION_TIMEOUT_MS,
695+
timeoutMs: compactionTimeoutMs,
694696
}),
695697
});
696698
try {
@@ -915,8 +917,15 @@ export async function compactEmbeddedPiSessionDirect(
915917
// If token estimation throws on a malformed message, fall back to 0 so
916918
// the sanity check below becomes a no-op instead of crashing compaction.
917919
}
918-
const result = await compactWithSafetyTimeout(() =>
919-
session.compact(params.customInstructions),
920+
const result = await compactWithSafetyTimeout(
921+
() => session.compact(params.customInstructions),
922+
compactionTimeoutMs,
923+
{
924+
abortSignal: params.abortSignal,
925+
onCancel: () => {
926+
session.abortCompaction();
927+
},
928+
},
920929
);
921930
await runPostCompactionSideEffects({
922931
config: params.config,
Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,88 @@
1+
import type { OpenClawConfig } from "../../config/config.js";
12
import { withTimeout } from "../../node-host/with-timeout.js";
23

3-
export const EMBEDDED_COMPACTION_TIMEOUT_MS = 300_000;
4+
export const EMBEDDED_COMPACTION_TIMEOUT_MS = 900_000;
5+
6+
const MAX_SAFE_TIMEOUT_MS = 2_147_000_000;
7+
8+
function createAbortError(signal: AbortSignal): Error {
9+
const reason = "reason" in signal ? signal.reason : undefined;
10+
if (reason instanceof Error) {
11+
return reason;
12+
}
13+
const err = reason ? new Error("aborted", { cause: reason }) : new Error("aborted");
14+
err.name = "AbortError";
15+
return err;
16+
}
17+
18+
export function resolveCompactionTimeoutMs(cfg?: OpenClawConfig): number {
19+
const raw = cfg?.agents?.defaults?.compaction?.timeoutSeconds;
20+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
21+
return Math.min(Math.floor(raw) * 1000, MAX_SAFE_TIMEOUT_MS);
22+
}
23+
return EMBEDDED_COMPACTION_TIMEOUT_MS;
24+
}
425

526
export async function compactWithSafetyTimeout<T>(
627
compact: () => Promise<T>,
728
timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS,
29+
opts?: {
30+
abortSignal?: AbortSignal;
31+
onCancel?: () => void;
32+
},
833
): Promise<T> {
9-
return await withTimeout(() => compact(), timeoutMs, "Compaction");
34+
let canceled = false;
35+
const cancel = () => {
36+
if (canceled) {
37+
return;
38+
}
39+
canceled = true;
40+
opts?.onCancel?.();
41+
};
42+
43+
return await withTimeout(
44+
async (timeoutSignal) => {
45+
let timeoutListener: (() => void) | undefined;
46+
let externalAbortListener: (() => void) | undefined;
47+
let externalAbortPromise: Promise<never> | undefined;
48+
const abortSignal = opts?.abortSignal;
49+
50+
if (timeoutSignal) {
51+
timeoutListener = () => {
52+
cancel();
53+
};
54+
timeoutSignal.addEventListener("abort", timeoutListener, { once: true });
55+
}
56+
57+
if (abortSignal) {
58+
if (abortSignal.aborted) {
59+
cancel();
60+
throw createAbortError(abortSignal);
61+
}
62+
externalAbortPromise = new Promise((_, reject) => {
63+
externalAbortListener = () => {
64+
cancel();
65+
reject(createAbortError(abortSignal));
66+
};
67+
abortSignal.addEventListener("abort", externalAbortListener, { once: true });
68+
});
69+
}
70+
71+
try {
72+
if (externalAbortPromise) {
73+
return await Promise.race([compact(), externalAbortPromise]);
74+
}
75+
return await compact();
76+
} finally {
77+
if (timeoutListener) {
78+
timeoutSignal?.removeEventListener("abort", timeoutListener);
79+
}
80+
if (externalAbortListener) {
81+
abortSignal?.removeEventListener("abort", externalAbortListener);
82+
}
83+
}
84+
},
85+
timeoutMs,
86+
"Compaction",
87+
);
1088
}

0 commit comments

Comments
 (0)