Skip to content

Commit 505e878

Browse files
authored
Merge branch 'main' into trust-windows-v2
2 parents 933a09b + f77a684 commit 505e878

13 files changed

Lines changed: 330 additions & 32 deletions

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)