From d81593c6e256c46ce08197ce7afe50fdc1a7c3e4 Mon Sep 17 00:00:00 2001 From: Joseph Krug Date: Wed, 25 Mar 2026 15:51:36 -0400 Subject: [PATCH] fix: trigger compaction on LLM timeout with high context usage (#46417) Merged via squash. Prepared head SHA: 619bc4c1fa1db3829ff3aa78ffcab8e4201379b5 Co-authored-by: joeykrug <5925937+joeykrug@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 3 + src/agents/pi-embedded-runner/compact.ts | 7 +- .../run.overflow-compaction.harness.ts | 37 +- .../run.overflow-compaction.test.ts | 14 +- .../run.timeout-triggered-compaction.test.ts | 561 ++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 224 +++++-- src/cli/program/preaction.test.ts | 2 + src/infra/retry.test.ts | 53 +- src/memory/manager.mistral-provider.test.ts | 10 +- src/memory/manager.vector-dedupe.test.ts | 3 + src/process/exec.test.ts | 10 +- 11 files changed, 843 insertions(+), 81 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab013765050..2491ac84bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Telegram/pairing: ignore self-authored DM `message` updates so bot-pinned status cards and similar service updates do not trigger bogus pairing requests or re-enter inbound dispatch. (#54530) thanks @huntharo - iMessage: stop leaking inline `[[reply_to:...]]` tags into delivered text by sending `reply_to` as RPC metadata and stripping stray directive tags from outbound messages. (#39512) Thanks @mvanhorn. - Agents/embedded replies: surface mid-turn 429 and overload failures when embedded runs end without a user-visible reply, while preserving successful media-only replies that still use legacy `mediaUrl`. (#50930) Thanks @infichen. +- Agents/compaction: trigger timeout recovery compaction before retrying high-context LLM timeouts so embedded runs stop repeating oversized requests. (#46417) thanks @joeykrug. ## 2026.3.24 @@ -425,6 +426,8 @@ Docs: https://docs.openclaw.ai - 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. (#46790) Thanks @vincentkoc. - 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. - Gateway/Telegram shutdown: abort stalled Telegram polling fetches on shutdown, clean up per-cycle abort listeners, and keep the in-process watchdog ahead of supervisor stop timeouts so SIGTERM no longer leaves zombie gateways behind. (#51242) Thanks @juliabush. +- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026. +- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava. - Telegram/setup: warn when setup leaves DMs on pairing without an allowlist, and show valid account-scoped remediation commands. (#50710) Thanks @ernestodeoliveira. - Doctor/Telegram: replace the fresh-install empty group-allowlist false positive with first-run guidance that explains DM pairing approval and the next group setup steps, so new Telegram installs get actionable setup help instead of a broken-config warning. Thanks @vincentkoc. - Doctor/extensions: keep Matrix DM `allowFrom` repairs on the canonical `dm.allowFrom` path and stop treating Zalouser group sender gating as if it fell back to `allowFrom`, so doctor warnings and `--fix` stay aligned with runtime access control. Thanks @vincentkoc. diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 13cc4089703..7d0b17eb5dc 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -329,7 +329,7 @@ function syncPostCompactionSessionMemory(params: { return Promise.resolve(); } -async function runPostCompactionSideEffects(params: { +export async function runPostCompactionSideEffects(params: { config?: OpenClawConfig; sessionKey?: string; sessionFile: string; @@ -731,7 +731,10 @@ export async function compactEmbeddedPiSessionDirect( config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, - warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), + warn: makeBootstrapWarn({ + sessionLabel, + warn: (message) => log.warn(message), + }), }); // Apply contextTokens cap to model so pi-coding-agent's auto-compaction // threshold uses the effective limit, not the native context window. diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts index 10c13dfe6fc..c415afa96d5 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts @@ -1,4 +1,4 @@ -import { vi, type Mock } from "vitest"; +import { type Mock, vi } from "vitest"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { PluginHookAgentContext, @@ -62,6 +62,7 @@ export const mockedContextEngine = { export const mockedContextEngineCompact = mockedContextEngine.compact; export const mockedCompactDirect = mockedContextEngine.compact; +export const mockedRunPostCompactionSideEffects = vi.fn(async () => {}); export const mockedEnsureRuntimePluginsLoaded = vi.fn<(params?: unknown) => void>(); export const mockedPrepareProviderRuntimeAuth = vi.fn(async () => undefined); export const mockedRunEmbeddedAttempt = @@ -137,6 +138,15 @@ export const mockedIsLikelyContextOverflowError = vi.fn((msg?: string) => { export const mockedPickFallbackThinkingLevel = vi.fn<(params?: unknown) => ThinkLevel | null>( () => null, ); +export const mockedGetApiKeyForModel = vi.fn( + async ({ profileId }: { profileId?: string } = {}) => ({ + apiKey: "test-key", + profileId: profileId ?? "test-profile", + source: "test", + mode: "api-key" as const, + }), +); +export const mockedResolveAuthProfileOrder = vi.fn(() => [] as string[]); export const overflowBaseRunParams = { sessionId: "test-session", @@ -226,6 +236,19 @@ export function resetRunOverflowCompactionHarnessMocks(): void { }); mockedPickFallbackThinkingLevel.mockReset(); mockedPickFallbackThinkingLevel.mockReturnValue(null); + mockedGetApiKeyForModel.mockReset(); + mockedGetApiKeyForModel.mockImplementation( + async ({ profileId }: { profileId?: string } = {}) => ({ + apiKey: "test-key", + profileId: profileId ?? "test-profile", + source: "test", + mode: "api-key", + }), + ); + mockedResolveAuthProfileOrder.mockReset(); + mockedResolveAuthProfileOrder.mockReturnValue([]); + mockedRunPostCompactionSideEffects.mockReset(); + mockedRunPostCompactionSideEffects.mockResolvedValue(undefined); } export async function loadRunOverflowCompactionHarness(): Promise<{ @@ -329,12 +352,8 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ vi.doMock("../model-auth.js", () => ({ applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), ensureAuthProfileStore: vi.fn(() => ({})), - getApiKeyForModel: vi.fn(async () => ({ - apiKey: "test-key", - profileId: "test-profile", - source: "test", - })), - resolveAuthProfileOrder: vi.fn(() => []), + getApiKeyForModel: mockedGetApiKeyForModel, + resolveAuthProfileOrder: mockedResolveAuthProfileOrder, })); vi.doMock("../models-config.js", () => ({ @@ -399,6 +418,10 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ sessionLikelyHasOversizedToolResults: mockedSessionLikelyHasOversizedToolResults, })); + vi.doMock("./compact.js", () => ({ + runPostCompactionSideEffects: mockedRunPostCompactionSideEffects, + })); + vi.doMock("./utils.js", () => ({ describeUnknownError: vi.fn((err: unknown) => { if (err instanceof Error) { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 56b4fbf0186..35190a4948d 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -9,18 +9,18 @@ import { import { loadRunOverflowCompactionHarness, mockedCoerceToFailoverError, + mockedCompactDirect, + mockedContextEngine, mockedDescribeFailoverError, mockedGlobalHookRunner, mockedPickFallbackThinkingLevel, mockedResolveFailoverStatus, - mockedContextEngine, - mockedCompactDirect, - mockedRunEmbeddedAttempt, mockedRunContextEngineMaintenance, - resetRunOverflowCompactionHarnessMocks, + mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams, + resetRunOverflowCompactionHarnessMocks, } from "./run.overflow-compaction.harness.js"; let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; @@ -46,6 +46,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedGlobalHookRunner.runBeforeAgentStart.mockReset(); mockedGlobalHookRunner.runBeforeCompaction.mockReset(); mockedGlobalHookRunner.runAfterCompaction.mockReset(); + mockedPickFallbackThinkingLevel.mockReset(); mockedContextEngine.info.ownsCompaction = false; mockedCompactDirect.mockResolvedValue({ ok: false, @@ -66,6 +67,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { truncatedCount: 0, reason: "no oversized tool results", }); + mockedPickFallbackThinkingLevel.mockReturnValue(null); mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); }); @@ -300,7 +302,9 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedPickFallbackThinkingLevel.mockReset(); mockedPickFallbackThinkingLevel.mockReturnValue(null); mockedRunEmbeddedAttempt.mockResolvedValue( - makeAttemptResult({ promptError: new Error("unsupported reasoning mode") }), + makeAttemptResult({ + promptError: new Error("unsupported reasoning mode"), + }), ); mockedPickFallbackThinkingLevel.mockReturnValue("low"); diff --git a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts b/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts new file mode 100644 index 00000000000..9ecf0b80587 --- /dev/null +++ b/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts @@ -0,0 +1,561 @@ +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { makeAttemptResult, makeCompactionSuccess } from "./run.overflow-compaction.fixture.js"; +import { + loadRunOverflowCompactionHarness, + mockedCoerceToFailoverError, + mockedCompactDirect, + mockedContextEngine, + mockedDescribeFailoverError, + mockedGetApiKeyForModel, + mockedGlobalHookRunner, + mockedPickFallbackThinkingLevel, + mockedResolveAuthProfileOrder, + mockedResolveFailoverStatus, + mockedRunEmbeddedAttempt, + mockedRunPostCompactionSideEffects, + mockedSessionLikelyHasOversizedToolResults, + mockedTruncateOversizedToolResultsInSession, + overflowBaseRunParams, + resetRunOverflowCompactionHarnessMocks, +} from "./run.overflow-compaction.harness.js"; + +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; + +const useTwoAuthProfiles = () => { + mockedResolveAuthProfileOrder.mockReturnValue(["profile-a", "profile-b"]); + mockedGetApiKeyForModel.mockImplementation(async ({ profileId } = {}) => ({ + apiKey: `test-key-${profileId ?? "profile-a"}`, + profileId: profileId ?? "profile-a", + source: "test", + mode: "api-key", + })); +}; + +describe("timeout-triggered compaction", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + + beforeEach(() => { + resetRunOverflowCompactionHarnessMocks(); + mockedRunEmbeddedAttempt.mockReset(); + mockedCompactDirect.mockReset(); + mockedCoerceToFailoverError.mockReset(); + mockedDescribeFailoverError.mockReset(); + mockedResolveFailoverStatus.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReset(); + mockedTruncateOversizedToolResultsInSession.mockReset(); + mockedGlobalHookRunner.runBeforeAgentStart.mockReset(); + mockedGlobalHookRunner.runBeforeCompaction.mockReset(); + mockedGlobalHookRunner.runAfterCompaction.mockReset(); + mockedPickFallbackThinkingLevel.mockReset(); + mockedRunPostCompactionSideEffects.mockReset(); + mockedRunPostCompactionSideEffects.mockResolvedValue(undefined); + mockedContextEngine.info.ownsCompaction = false; + mockedCompactDirect.mockResolvedValue({ + ok: false, + compacted: false, + reason: "nothing to compact", + }); + mockedCoerceToFailoverError.mockReturnValue(null); + mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + })); + mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false); + mockedTruncateOversizedToolResultsInSession.mockResolvedValue({ + truncated: false, + truncatedCount: 0, + reason: "no oversized tool results", + }); + mockedPickFallbackThinkingLevel.mockReturnValue(null); + mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); + mockedGetApiKeyForModel.mockImplementation(async ({ profileId } = {}) => ({ + apiKey: "test-key", + profileId: profileId ?? "test-profile", + source: "test", + mode: "api-key", + })); + mockedResolveAuthProfileOrder.mockReturnValue([]); + }); + + it("attempts compaction when LLM times out with high prompt token usage (>65%)", async () => { + // First attempt: timeout with high prompt usage (150k / 200k = 75%) + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 150000 }, + } as never, + }), + ); + // Compaction succeeds + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "timeout recovery compaction", + tokensBefore: 150000, + tokensAfter: 80000, + }), + ); + // Retry after compaction succeeds + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + expect(mockedCompactDirect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "test-session", + sessionFile: "/tmp/session.json", + tokenBudget: 200000, + force: true, + compactionTarget: "budget", + runtimeContext: expect.objectContaining({ + trigger: "timeout_recovery", + attempt: 1, + maxAttempts: 2, + }), + }), + ); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(result.meta.error).toBeUndefined(); + }); + + it("retries the prompt after successful timeout compaction", async () => { + // First attempt: timeout with high prompt usage + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 160000 }, + } as never, + }), + ); + // Compaction succeeds + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "compacted for timeout", + tokensBefore: 160000, + tokensAfter: 60000, + }), + ); + // Second attempt succeeds + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + // Verify the loop continued (retry happened) + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(mockedRunPostCompactionSideEffects).not.toHaveBeenCalled(); + expect(result.meta.error).toBeUndefined(); + }); + + it("passes channel, thread, message, and sender context into timeout compaction", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 160000 }, + } as never, + }), + ); + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "compacted with full runtime context", + tokensBefore: 160000, + tokensAfter: 60000, + }), + ); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "channel-1", + currentThreadTs: "thread-1", + currentMessageId: "message-1", + senderId: "sender-1", + senderIsOwner: true, + }); + + expect(mockedCompactDirect).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeContext: expect.objectContaining({ + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "channel-1", + currentThreadTs: "thread-1", + currentMessageId: "message-1", + senderId: "sender-1", + senderIsOwner: true, + }), + }), + ); + }); + + it("falls through to normal handling when timeout compaction fails", async () => { + // Timeout with high prompt usage + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 150000 }, + } as never, + }), + ); + // Compaction does not reduce context + mockedCompactDirect.mockResolvedValueOnce({ + ok: false, + compacted: false, + reason: "nothing to compact", + }); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + // Compaction was attempted but failed → falls through to timeout error payload + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + + it("does not attempt compaction when prompt token usage is low", async () => { + // Timeout with low prompt usage (20k / 200k = 10%) + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 20000 }, + } as never, + }), + ); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + // No compaction attempt for low usage + expect(mockedCompactDirect).not.toHaveBeenCalled(); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + + it("does not attempt compaction for low-context timeouts on later retries", async () => { + mockedPickFallbackThinkingLevel.mockReturnValueOnce("low"); + mockedRunEmbeddedAttempt + .mockResolvedValueOnce( + makeAttemptResult({ + promptError: new Error("unsupported reasoning mode"), + }), + ) + .mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 20000 }, + } as never, + }), + ); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(mockedCompactDirect).not.toHaveBeenCalled(); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + + it("still attempts compaction for timed-out attempts that set aborted", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + aborted: true, + lastAssistant: { + usage: { input: 180000 }, + } as never, + }), + ); + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "timeout recovery compaction", + tokensBefore: 180000, + tokensAfter: 90000, + }), + ); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(result.meta.error).toBeUndefined(); + }); + + it("does not attempt compaction when timedOutDuringCompaction is true", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + timedOutDuringCompaction: true, + lastAssistant: { + usage: { input: 180000 }, + } as never, + }), + ); + + await runEmbeddedPiAgent(overflowBaseRunParams); + + // timedOutDuringCompaction skips timeout-triggered compaction + expect(mockedCompactDirect).not.toHaveBeenCalled(); + }); + + it("falls through to failover rotation after max timeout compaction attempts", async () => { + // First attempt: timeout with high prompt usage (150k / 200k = 75%) + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 150000 }, + } as never, + }), + ); + // First compaction succeeds + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "timeout recovery compaction 1", + tokensBefore: 150000, + tokensAfter: 80000, + }), + ); + // Second attempt after compaction: also times out with high usage + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 140000 }, + } as never, + }), + ); + // Second compaction also succeeds + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "timeout recovery compaction 2", + tokensBefore: 140000, + tokensAfter: 70000, + }), + ); + // Third attempt after second compaction: still times out + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 130000 }, + } as never, + }), + ); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + // Both compaction attempts used; third timeout falls through. + expect(mockedCompactDirect).toHaveBeenCalledTimes(2); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(3); + // Falls through to timeout error payload (failover rotation path) + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + + it("catches thrown errors from contextEngine.compact during timeout recovery", async () => { + // Timeout with high prompt usage + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 150000 }, + } as never, + }), + ); + // Compaction throws + mockedCompactDirect.mockRejectedValueOnce(new Error("engine crashed")); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + // Should not crash — falls through to normal timeout handling + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + + it("fires compaction hooks during timeout recovery for ownsCompaction engines", async () => { + mockedContextEngine.info.ownsCompaction = true; + mockedGlobalHookRunner.hasHooks.mockImplementation( + (hookName) => hookName === "before_compaction" || hookName === "after_compaction", + ); + mockedRunEmbeddedAttempt + .mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 160000 }, + } as never, + }), + ) + .mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + mockedCompactDirect.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { + summary: "engine-owned timeout compaction", + tokensAfter: 70, + }, + }); + + await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledWith( + { messageCount: -1, sessionFile: "/tmp/session.json" }, + expect.objectContaining({ + sessionKey: "test-key", + }), + ); + expect(mockedGlobalHookRunner.runAfterCompaction).toHaveBeenCalledWith( + { + messageCount: -1, + compactedCount: -1, + tokenCount: 70, + sessionFile: "/tmp/session.json", + }, + expect.objectContaining({ + sessionKey: "test-key", + }), + ); + expect(mockedRunPostCompactionSideEffects).toHaveBeenCalledTimes(1); + }); + + it("counts compacted:false timeout compactions against the retry cap across profile rotation", async () => { + useTwoAuthProfiles(); + // Attempt 1 (profile-a): timeout → compaction #1 fails → rotate to profile-b + mockedRunEmbeddedAttempt + .mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + aborted: true, + lastAssistant: { + usage: { input: 150000 }, + } as never, + }), + ) + // Attempt 2 (profile-b): timeout → compaction #2 fails → cap exhausted → rotation + .mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + aborted: true, + lastAssistant: { + usage: { input: 150000 }, + } as never, + }), + ); + mockedCompactDirect + .mockResolvedValueOnce({ + ok: false, + compacted: false, + reason: "nothing to compact", + }) + .mockResolvedValueOnce({ + ok: false, + compacted: false, + reason: "nothing to compact", + }); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedCompactDirect).toHaveBeenCalledTimes(2); + expect(mockedCompactDirect).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + runtimeContext: expect.objectContaining({ + authProfileId: "profile-a", + attempt: 1, + maxAttempts: 2, + }), + }), + ); + expect(mockedCompactDirect).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + runtimeContext: expect.objectContaining({ + authProfileId: "profile-b", + attempt: 2, + maxAttempts: 2, + }), + }), + ); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + + it("counts thrown timeout compactions against the retry cap across profile rotation", async () => { + useTwoAuthProfiles(); + // Attempt 1 (profile-a): timeout → compaction #1 throws → rotate to profile-b + mockedRunEmbeddedAttempt + .mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + aborted: true, + lastAssistant: { + usage: { input: 150000 }, + } as never, + }), + ) + // Attempt 2 (profile-b): timeout → compaction #2 throws → cap exhausted → rotation + .mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + aborted: true, + lastAssistant: { + usage: { input: 150000 }, + } as never, + }), + ); + mockedCompactDirect + .mockRejectedValueOnce(new Error("engine crashed")) + .mockRejectedValueOnce(new Error("engine crashed again")); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedCompactDirect).toHaveBeenCalledTimes(2); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(mockedRunEmbeddedAttempt).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ authProfileId: "profile-a" }), + ); + expect(mockedRunEmbeddedAttempt).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ authProfileId: "profile-b" }), + ); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + + it("uses prompt/input tokens for ratio, not total tokens", async () => { + // Timeout where total tokens are high (150k) but input/prompt tokens + // are low (20k / 200k = 10%). Should NOT trigger compaction because + // the ratio is based on prompt tokens, not total. + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 20000, total: 150000 }, + } as never, + }), + ); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + // Despite high total tokens, low prompt tokens mean no compaction + expect(mockedCompactDirect).not.toHaveBeenCalled(); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); +}); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index d25838d2b92..22e26cd9628 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -5,7 +5,7 @@ import { ensureContextEnginesInitialized, resolveContextEngine, } from "../../context-engine/index.js"; -import { computeBackoff, sleepWithAbort, type BackoffPolicy } from "../../infra/backoff.js"; +import { type BackoffPolicy, computeBackoff, sleepWithAbort } from "../../infra/backoff.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; @@ -15,8 +15,8 @@ import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js" import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { hasConfiguredModelFallbacks } from "../agent-scope.js"; import { - isProfileInCooldown, type AuthProfileFailureReason, + isProfileInCooldown, markAuthProfileFailure, markAuthProfileGood, markAuthProfileUsed, @@ -40,33 +40,34 @@ import { applyLocalNoAuthHeaderOverride, ensureAuthProfileStore, getApiKeyForModel, - resolveAuthProfileOrder, type ResolvedProviderAuth, + resolveAuthProfileOrder, } from "../model-auth.js"; import { normalizeProviderId } from "../model-selection.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { - formatBillingErrorMessage, classifyFailoverReason, extractObservedOverflowTokenCount, + type FailoverReason, formatAssistantErrorText, + formatBillingErrorMessage, isAuthAssistantError, isBillingAssistantError, isCompactionFailureError, - isLikelyContextOverflowError, isFailoverAssistantError, isFailoverErrorMessage, - parseImageSizeError, - parseImageDimensionError, + isLikelyContextOverflowError, isRateLimitAssistantError, isTimeoutErrorMessage, + parseImageDimensionError, + parseImageSizeError, pickFallbackThinkingLevel, - type FailoverReason, } from "../pi-embedded-helpers.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { isLikelyMutatingToolName } from "../tool-mutation.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; +import { runPostCompactionSideEffects } from "./compact.js"; import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; @@ -77,8 +78,8 @@ import { createFailoverDecisionLogger } from "./run/failover-observation.js"; import type { RunEmbeddedPiAgentParams } from "./run/params.js"; import { buildEmbeddedRunPayloads } from "./run/payloads.js"; import { - truncateOversizedToolResultsInSession, sessionLikelyHasOversizedToolResults, + truncateOversizedToolResultsInSession, } from "./tool-result-truncation.js"; import type { EmbeddedPiAgentMeta, EmbeddedPiRunResult } from "./types.js"; import { @@ -365,7 +366,9 @@ export async function runEmbeddedPiAgent( ); } - const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const authStore = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const preferredProfileId = params.authProfileId?.trim(); let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined; if (lockedProfileId) { @@ -458,7 +461,10 @@ export async function runEmbeddedPiAgent( authStorage.setRuntimeApiKey(runtimeModel.provider, preparedAuth.apiKey); if (preparedAuth.baseUrl) { runtimeModel = { ...runtimeModel, baseUrl: preparedAuth.baseUrl }; - effectiveModel = { ...effectiveModel, baseUrl: preparedAuth.baseUrl }; + effectiveModel = { + ...effectiveModel, + baseUrl: preparedAuth.baseUrl, + }; } runtimeAuthState = { ...runtimeAuthState, @@ -761,6 +767,7 @@ export async function runEmbeddedPiAgent( } }; + const MAX_TIMEOUT_COMPACTION_ATTEMPTS = 2; const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3; const MAX_RUN_LOOP_ITERATIONS = resolveMaxRunRetryIterations(profileCandidates.length); let overflowCompactionAttempts = 0; @@ -773,6 +780,7 @@ export async function runEmbeddedPiAgent( let autoCompactionCount = 0; let runLoopIterations = 0; let overloadFailoverAttempts = 0; + let timeoutCompactionAttempts = 0; const maybeMarkAuthProfileFailure = async (failure: { profileId?: string; reason?: AuthProfileFailureReason | null; @@ -829,6 +837,51 @@ export async function runEmbeddedPiAgent( ensureContextEnginesInitialized(); const contextEngine = await resolveContextEngine(params.config); try { + // When the engine owns compaction, compactEmbeddedPiSessionDirect is + // bypassed. Fire lifecycle hooks here so recovery paths still notify + // subscribers like memory extensions and usage trackers. + const runOwnsCompactionBeforeHook = async (reason: string) => { + if ( + contextEngine.info.ownsCompaction !== true || + !hookRunner?.hasHooks("before_compaction") + ) { + return; + } + try { + await hookRunner.runBeforeCompaction( + { messageCount: -1, sessionFile: params.sessionFile }, + hookCtx, + ); + } catch (hookErr) { + log.warn(`before_compaction hook failed during ${reason}: ${String(hookErr)}`); + } + }; + const runOwnsCompactionAfterHook = async ( + reason: string, + compactResult: Awaited>, + ) => { + if ( + contextEngine.info.ownsCompaction !== true || + !compactResult.ok || + !compactResult.compacted || + !hookRunner?.hasHooks("after_compaction") + ) { + return; + } + try { + await hookRunner.runAfterCompaction( + { + messageCount: -1, + compactedCount: -1, + tokenCount: compactResult.result?.tokensAfter, + sessionFile: params.sessionFile, + }, + hookCtx, + ); + } catch (hookErr) { + log.warn(`after_compaction hook failed during ${reason}: ${String(hookErr)}`); + } + }; let authRetryPending = false; // Hoisted so the retry-limit error path can use the most recent API total. let lastTurnTotal: number | undefined; @@ -996,6 +1049,103 @@ export async function runEmbeddedPiAgent( ? lastAssistant.errorMessage?.trim() || formattedAssistantErrorText : undefined; + // ── Timeout-triggered compaction ────────────────────────────────── + // When the LLM times out with high context usage, compact before + // retrying to break the death spiral of repeated timeouts. + if (timedOut && !timedOutDuringCompaction) { + // Only consider prompt-side tokens here. API totals include output + // tokens, which can make a long generation look like high context + // pressure even when the prompt itself was small. + const lastTurnPromptTokens = derivePromptTokens(lastRunPromptUsage); + const tokenUsedRatio = + lastTurnPromptTokens != null && ctxInfo.tokens > 0 + ? lastTurnPromptTokens / ctxInfo.tokens + : 0; + if (timeoutCompactionAttempts >= MAX_TIMEOUT_COMPACTION_ATTEMPTS) { + log.warn( + `[timeout-compaction] already attempted timeout compaction ${timeoutCompactionAttempts} time(s); falling through to failover rotation`, + ); + } else if (tokenUsedRatio > 0.65) { + const timeoutDiagId = createCompactionDiagId(); + timeoutCompactionAttempts++; + log.warn( + `[timeout-compaction] LLM timed out with high prompt token usage (${Math.round(tokenUsedRatio * 100)}%); ` + + `attempting compaction before retry (attempt ${timeoutCompactionAttempts}/${MAX_TIMEOUT_COMPACTION_ATTEMPTS}) diagId=${timeoutDiagId}`, + ); + let timeoutCompactResult: Awaited>; + await runOwnsCompactionBeforeHook("timeout recovery"); + try { + const timeoutCompactionRuntimeContext = { + ...buildEmbeddedCompactionRuntimeContext({ + sessionKey: params.sessionKey, + messageChannel: params.messageChannel, + messageProvider: params.messageProvider, + agentAccountId: params.agentAccountId, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + authProfileId: lastProfileId, + workspaceDir: resolvedWorkspace, + agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId, + provider, + modelId, + thinkLevel, + reasoningLevel: params.reasoningLevel, + bashElevated: params.bashElevated, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + }), + runId: params.runId, + trigger: "timeout_recovery", + diagId: timeoutDiagId, + attempt: timeoutCompactionAttempts, + maxAttempts: MAX_TIMEOUT_COMPACTION_ATTEMPTS, + }; + timeoutCompactResult = await contextEngine.compact({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + tokenBudget: ctxInfo.tokens, + force: true, + compactionTarget: "budget", + runtimeContext: timeoutCompactionRuntimeContext, + }); + } catch (compactErr) { + log.warn( + `[timeout-compaction] contextEngine.compact() threw during timeout recovery for ${provider}/${modelId}: ${String(compactErr)}`, + ); + timeoutCompactResult = { + ok: false, + compacted: false, + reason: String(compactErr), + }; + } + await runOwnsCompactionAfterHook("timeout recovery", timeoutCompactResult); + if (timeoutCompactResult.compacted) { + autoCompactionCount += 1; + if (contextEngine.info.ownsCompaction === true) { + await runPostCompactionSideEffects({ + config: params.config, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + }); + } + log.info( + `[timeout-compaction] compaction succeeded for ${provider}/${modelId}; retrying prompt`, + ); + continue; + } else { + log.warn( + `[timeout-compaction] compaction did not reduce context for ${provider}/${modelId}; falling through to normal handling`, + ); + } + } + } + const contextOverflowError = !aborted ? (() => { if (promptError) { @@ -1008,7 +1158,10 @@ export async function runEmbeddedPiAgent( return null; } if (assistantErrorText && isLikelyContextOverflowError(assistantErrorText)) { - return { text: assistantErrorText, source: "assistantError" as const }; + return { + text: assistantErrorText, + source: "assistantError" as const, + }; } return null; })() @@ -1061,24 +1214,7 @@ export async function runEmbeddedPiAgent( `context overflow detected (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); attempting auto-compaction for ${provider}/${modelId}`, ); let compactResult: Awaited>; - // When the engine owns compaction, hooks are not fired inside - // compactEmbeddedPiSessionDirect (which is bypassed). Fire them - // here so subscribers (memory extensions, usage trackers) are - // notified even on overflow-recovery compactions. - const overflowEngineOwnsCompaction = contextEngine.info.ownsCompaction === true; - const overflowHookRunner = overflowEngineOwnsCompaction ? hookRunner : null; - if (overflowHookRunner?.hasHooks("before_compaction")) { - try { - await overflowHookRunner.runBeforeCompaction( - { messageCount: -1, sessionFile: params.sessionFile }, - hookCtx, - ); - } catch (hookErr) { - log.warn( - `before_compaction hook failed during overflow recovery: ${String(hookErr)}`, - ); - } - } + await runOwnsCompactionBeforeHook("overflow recovery"); try { const overflowCompactionRuntimeContext = { ...buildEmbeddedCompactionRuntimeContext({ @@ -1139,29 +1275,13 @@ export async function runEmbeddedPiAgent( log.warn( `contextEngine.compact() threw during overflow recovery for ${provider}/${modelId}: ${String(compactErr)}`, ); - compactResult = { ok: false, compacted: false, reason: String(compactErr) }; - } - if ( - compactResult.ok && - compactResult.compacted && - overflowHookRunner?.hasHooks("after_compaction") - ) { - try { - await overflowHookRunner.runAfterCompaction( - { - messageCount: -1, - compactedCount: -1, - tokenCount: compactResult.result?.tokensAfter, - sessionFile: params.sessionFile, - }, - hookCtx, - ); - } catch (hookErr) { - log.warn( - `after_compaction hook failed during overflow recovery: ${String(hookErr)}`, - ); - } + compactResult = { + ok: false, + compacted: false, + reason: String(compactErr), + }; } + await runOwnsCompactionAfterHook("overflow recovery", compactResult); if (compactResult.compacted) { autoCompactionCount += 1; log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`); diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index dfb1a78bb3c..e96ea748f46 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -83,6 +83,8 @@ beforeEach(() => { originalNodeNoWarnings = process.env.NODE_NO_WARNINGS; originalHideBanner = process.env.OPENCLAW_HIDE_BANNER; originalForceStderr = loggingState.forceConsoleToStderr; + // Worker-thread Vitest runs do not reliably mutate the real process title, + // so capture writes at the property boundary instead. Object.defineProperty(process, "title", { configurable: true, enumerable: originalProcessTitleDescriptor?.enumerable ?? true, diff --git a/src/infra/retry.test.ts b/src/infra/retry.test.ts index a320da5bac7..a06b7532a2b 100644 --- a/src/infra/retry.test.ts +++ b/src/infra/retry.test.ts @@ -28,6 +28,31 @@ async function runRetryAfterCase(params: { } } +async function runRetryNumberCase( + fn: ReturnType Promise>>, + attempts: number, + initialDelayMs: number, +): Promise { + vi.clearAllTimers(); + vi.useFakeTimers(); + try { + const promise = retryAsync(fn, attempts, initialDelayMs); + const settled = promise.then( + (value) => ({ ok: true as const, value }), + (error) => ({ ok: false as const, error }), + ); + await vi.runAllTimersAsync(); + const result = await settled; + if (result.ok) { + return result.value; + } + throw result.error; + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } +} + afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); @@ -48,14 +73,14 @@ describe("retryAsync", () => { it("retries then succeeds", async () => { const fn = vi.fn().mockRejectedValueOnce(new Error("fail1")).mockResolvedValueOnce("ok"); - const result = await retryAsync(fn, 3, 1); + const result = await runRetryNumberCase(fn, 3, 1); expect(result).toBe("ok"); expect(fn).toHaveBeenCalledTimes(2); }); it("propagates after exhausting retries", async () => { const fn = vi.fn().mockRejectedValue(new Error("boom")); - await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom"); + await expect(runRetryNumberCase(fn, 2, 1)).rejects.toThrow("boom"); expect(fn).toHaveBeenCalledTimes(2); }); @@ -72,13 +97,23 @@ describe("retryAsync", () => { const err = new Error("boom"); const fn = vi.fn().mockRejectedValueOnce(err).mockResolvedValueOnce("ok"); const onRetry = vi.fn(); - const res = await retryAsync(fn, { - attempts: 2, - minDelayMs: 0, - maxDelayMs: 0, - label: "telegram", - onRetry, - }); + vi.clearAllTimers(); + vi.useFakeTimers(); + let res: string; + try { + const promise: Promise = retryAsync(fn, { + attempts: 2, + minDelayMs: 0, + maxDelayMs: 0, + label: "telegram", + onRetry, + }); + await vi.runAllTimersAsync(); + res = await promise; + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } expect(res).toBe("ok"); expect(onRetry).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/memory/manager.mistral-provider.test.ts b/src/memory/manager.mistral-provider.test.ts index 4265fd8b2cf..7e30117ce13 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/src/memory/manager.mistral-provider.test.ts @@ -113,7 +113,11 @@ describe("memory manager mistral provider wiring", () => { manager = result.manager as unknown as MemoryIndexManager; await manager.probeEmbeddingAvailability(); - const internal = manager as unknown as { mistral?: MistralEmbeddingClient }; + const internal = manager as unknown as { + ensureProviderInitialized: () => Promise; + mistral?: MistralEmbeddingClient; + }; + await internal.ensureProviderInitialized(); expect(internal.mistral).toBe(mistralClient); }); @@ -147,11 +151,13 @@ describe("memory manager mistral provider wiring", () => { manager = result.manager as unknown as MemoryIndexManager; await manager.probeEmbeddingAvailability(); const internal = manager as unknown as { + ensureProviderInitialized: () => Promise; activateFallbackProvider: (reason: string) => Promise; openAi?: OpenAiEmbeddingClient; mistral?: MistralEmbeddingClient; }; + await internal.ensureProviderInitialized(); const activated = await internal.activateFallbackProvider("forced test"); expect(activated).toBe(true); expect(internal.openAi).toBeUndefined(); @@ -189,11 +195,13 @@ describe("memory manager mistral provider wiring", () => { manager = result.manager as unknown as MemoryIndexManager; await manager.probeEmbeddingAvailability(); const internal = manager as unknown as { + ensureProviderInitialized: () => Promise; activateFallbackProvider: (reason: string) => Promise; openAi?: OpenAiEmbeddingClient; ollama?: OllamaEmbeddingClient; }; + await internal.ensureProviderInitialized(); const activated = await internal.activateFallbackProvider("forced ollama fallback"); expect(activated).toBe(true); expect(internal.openAi).toBeUndefined(); diff --git a/src/memory/manager.vector-dedupe.test.ts b/src/memory/manager.vector-dedupe.test.ts index 9ad889c1b5a..7af98a4efe6 100644 --- a/src/memory/manager.vector-dedupe.test.ts +++ b/src/memory/manager.vector-dedupe.test.ts @@ -91,6 +91,9 @@ describe("memory vector dedupe", () => { ( manager as unknown as { ensureVectorReady: (dims?: number) => Promise } ).ensureVectorReady = async () => true; + await ( + manager as unknown as { ensureProviderInitialized: () => Promise } + ).ensureProviderInitialized(); const entry = await buildFileEntry(path.join(workspaceDir, "MEMORY.md"), workspaceDir); if (!entry) { diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 88d9cfdd71e..792231cffe6 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -47,10 +47,10 @@ describe("runCommandWithTimeout", () => { it("kills command when no output timeout elapses", async () => { const result = await runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(() => {}, 10)"], + [process.execPath, "-e", "setTimeout(() => {}, 5_000)"], { - timeoutMs: 30, - noOutputTimeoutMs: 4, + timeoutMs: 2_000, + noOutputTimeoutMs: 200, }, ); @@ -61,9 +61,9 @@ describe("runCommandWithTimeout", () => { it("reports global timeout termination when overall timeout elapses", async () => { const result = await runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(() => {}, 10)"], + [process.execPath, "-e", "setTimeout(() => {}, 5_000)"], { - timeoutMs: 4, + timeoutMs: 200, }, );