fix: ensure anthropic-auto-compact lock is always cleared (#232)

Fixes #200

## Problem
When executeCompact() recovery fails unexpectedly or gets interrupted,
the compactionInProgress lock is never cleared, permanently blocking both
auto-compact AND manual /compact for the session.

## Root Cause
- No try/finally around lock acquisition (line 261)
- Silent blocking when lock held - no user feedback
- Lock cleanup scattered across 7 manual deletion points
- Any unexpected exception bypasses cleanup, leaving lock stuck forever

## Solution
1. **Try/Finally Lock Guarantee**: Wrapped entire executeCompact body in
   try/finally block to guarantee lock cleanup, following the pattern
   used in preemptive-compaction hook

2. **User Feedback**: Added toast notification when compact attempt is
   blocked by existing lock, replacing silent failure with clear warning

3. **Removed Redundancy**: Removed 6 redundant manual lock deletions
   (kept only clearSessionState and finally block)

## Testing Evidence
✅ 10/10 comprehensive tests pass
✅ Lock cleared on successful completion
✅ Lock cleared when summarize throws
✅ Lock cleared when revert throws
✅ Lock cleared when fixEmptyMessages executes
✅ Lock cleared when truncation is sufficient
✅ Lock cleared after max recovery attempts
✅ Lock cleared when toast fails
✅ Lock cleared when prompt_async throws
✅ Toast shown when lock already held
✅ TypeScript type check passes with zero errors

## Files Changed
- executor.ts: Added try/finally, toast notification, removed 6 redundant deletions
- executor.test.ts: New comprehensive test suite (10 tests, 13 assertions)

## Impact
- Severity: High → Fixed
- User Experience: No more stuck sessions requiring restart
- Behavior: Identical except lock now guaranteed to clear

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
Sisyphus
2025-12-25 19:36:22 +09:00
committed by GitHub
parent 0ef6038ec0
commit 21beae6ca4
2 changed files with 731 additions and 395 deletions
@@ -0,0 +1,260 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { executeCompact } from "./executor"
import type { AutoCompactState } from "./types"
describe("executeCompact lock management", () => {
let autoCompactState: AutoCompactState
let mockClient: any
const sessionID = "test-session-123"
const directory = "/test/dir"
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
beforeEach(() => {
// #given: Fresh state for each test
autoCompactState = {
pendingCompact: new Set<string>(),
errorDataBySession: new Map(),
retryStateBySession: new Map(),
fallbackStateBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
mockClient = {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
prompt_async: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
}
})
test("clears lock on successful summarize completion", async () => {
// #given: Valid session with providerID/modelID
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction successfully
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when summarize throws exception", async () => {
// #given: Summarize will fail
mockClient.session.summarize = mock(() =>
Promise.reject(new Error("Network timeout")),
)
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should still be cleared despite exception
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when revert throws exception", async () => {
// #given: Force revert path by exhausting retry attempts and making revert fail
mockClient.session.revert = mock(() =>
Promise.reject(new Error("Revert failed")),
)
mockClient.session.messages = mock(() =>
Promise.resolve({
data: [
{ info: { id: "msg1", role: "user" } },
{ info: { id: "msg2", role: "assistant" } },
],
}),
)
// Exhaust retry attempts
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 5,
lastAttemptTime: Date.now(),
})
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock cleared even though revert failed
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("shows toast when lock already held", async () => {
// #given: Lock already held
autoCompactState.compactionInProgress.add(sessionID)
// #when: Try to execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Toast should be shown with warning message
expect(mockClient.tui.showToast).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
title: "Compact In Progress",
message: expect.stringContaining("Recovery already running"),
variant: "warning",
}),
}),
)
// #then: compactionInProgress should still have the lock
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
})
test("clears lock when fixEmptyMessages path executes", async () => {
// #given: Empty content error scenario
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "non-empty content required",
messageIndex: 0,
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction (fixEmptyMessages will be called)
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when truncation is sufficient", async () => {
// #given: Aggressive truncation scenario with sufficient truncation
// This test verifies the early return path in aggressive truncation
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
maxTokens: 200000,
})
const experimental = {
truncate_all_tool_outputs: false,
aggressive_truncation: true,
}
// #when: Execute compaction with experimental flag
await executeCompact(
sessionID,
msg,
autoCompactState,
mockClient,
directory,
experimental,
)
// #then: Lock should be cleared even on early return
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("prevents concurrent compaction attempts", async () => {
// #given: Lock already held (simpler test)
autoCompactState.compactionInProgress.add(sessionID)
// #when: Try to execute compaction while lock is held
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Toast should be shown
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const blockedToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Compact In Progress",
)
expect(blockedToast).toBeDefined()
// #then: Lock should still be held (not cleared by blocked attempt)
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
})
test("clears lock after max recovery attempts exhausted", async () => {
// #given: All retry/revert attempts exhausted
mockClient.session.messages = mock(() => Promise.resolve({ data: [] }))
// Max out all attempts
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 5,
lastAttemptTime: Date.now(),
})
autoCompactState.fallbackStateBySession.set(sessionID, {
revertAttempt: 5,
})
autoCompactState.truncateStateBySession.set(sessionID, {
truncateAttempt: 5,
})
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Should show failure toast
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const failureToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
)
expect(failureToast).toBeDefined()
// #then: Lock should still be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when client.tui.showToast throws", async () => {
// #given: Toast will fail (this should never happen but testing robustness)
mockClient.tui.showToast = mock(() =>
Promise.reject(new Error("Toast failed")),
)
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared even if toast fails
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when prompt_async in continuation throws", async () => {
// #given: prompt_async will fail during continuation
mockClient.session.prompt_async = mock(() =>
Promise.reject(new Error("Prompt failed")),
)
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600))
// #then: Lock should be cleared
// The continuation happens in setTimeout, but lock is cleared in finally before that
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
})
+270 -194
View File
@@ -1,206 +1,238 @@
import type { AutoCompactState, FallbackState, RetryState, TruncateState } from "./types"
import type { ExperimentalConfig } from "../../config"
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage"
import type {
AutoCompactState,
FallbackState,
RetryState,
TruncateState,
} from "./types";
import type { ExperimentalConfig } from "../../config";
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
import {
findLargestToolResult,
truncateToolResult,
truncateUntilTargetTokens,
} from "./storage";
import {
findEmptyMessages,
findEmptyMessageByIndex,
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
import { log } from "../../shared/logger"
} from "../session-recovery/storage";
import { log } from "../../shared/logger";
type Client = {
session: {
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
messages: (opts: {
path: { id: string };
query?: { directory?: string };
}) => Promise<unknown>;
summarize: (opts: {
path: { id: string }
body: { providerID: string; modelID: string }
query: { directory: string }
}) => Promise<unknown>
path: { id: string };
body: { providerID: string; modelID: string };
query: { directory: string };
}) => Promise<unknown>;
revert: (opts: {
path: { id: string }
body: { messageID: string; partID?: string }
query: { directory: string }
}) => Promise<unknown>
path: { id: string };
body: { messageID: string; partID?: string };
query: { directory: string };
}) => Promise<unknown>;
prompt_async: (opts: {
path: { sessionID: string }
body: { parts: Array<{ type: string; text: string }> }
query: { directory: string }
}) => Promise<unknown>
}
path: { sessionID: string };
body: { parts: Array<{ type: string; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
};
tui: {
showToast: (opts: {
body: { title: string; message: string; variant: string; duration: number }
}) => Promise<unknown>
}
}
body: {
title: string;
message: string;
variant: string;
duration: number;
};
}) => Promise<unknown>;
};
};
function getOrCreateRetryState(
autoCompactState: AutoCompactState,
sessionID: string
sessionID: string,
): RetryState {
let state = autoCompactState.retryStateBySession.get(sessionID)
let state = autoCompactState.retryStateBySession.get(sessionID);
if (!state) {
state = { attempt: 0, lastAttemptTime: 0 }
autoCompactState.retryStateBySession.set(sessionID, state)
state = { attempt: 0, lastAttemptTime: 0 };
autoCompactState.retryStateBySession.set(sessionID, state);
}
return state
return state;
}
function getOrCreateFallbackState(
autoCompactState: AutoCompactState,
sessionID: string
sessionID: string,
): FallbackState {
let state = autoCompactState.fallbackStateBySession.get(sessionID)
let state = autoCompactState.fallbackStateBySession.get(sessionID);
if (!state) {
state = { revertAttempt: 0 }
autoCompactState.fallbackStateBySession.set(sessionID, state)
state = { revertAttempt: 0 };
autoCompactState.fallbackStateBySession.set(sessionID, state);
}
return state
return state;
}
function getOrCreateTruncateState(
autoCompactState: AutoCompactState,
sessionID: string
sessionID: string,
): TruncateState {
let state = autoCompactState.truncateStateBySession.get(sessionID)
let state = autoCompactState.truncateStateBySession.get(sessionID);
if (!state) {
state = { truncateAttempt: 0 }
autoCompactState.truncateStateBySession.set(sessionID, state)
state = { truncateAttempt: 0 };
autoCompactState.truncateStateBySession.set(sessionID, state);
}
return state
return state;
}
async function getLastMessagePair(
sessionID: string,
client: Client,
directory: string
directory: string,
): Promise<{ userMessageID: string; assistantMessageID?: string } | null> {
try {
const resp = await client.session.messages({
path: { id: sessionID },
query: { directory },
})
});
const data = (resp as { data?: unknown[] }).data
if (!Array.isArray(data) || data.length < FALLBACK_CONFIG.minMessagesRequired) {
return null
const data = (resp as { data?: unknown[] }).data;
if (
!Array.isArray(data) ||
data.length < FALLBACK_CONFIG.minMessagesRequired
) {
return null;
}
const reversed = [...data].reverse()
const reversed = [...data].reverse();
const lastAssistant = reversed.find((m) => {
const msg = m as Record<string, unknown>
const info = msg.info as Record<string, unknown> | undefined
return info?.role === "assistant"
})
const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "assistant";
});
const lastUser = reversed.find((m) => {
const msg = m as Record<string, unknown>
const info = msg.info as Record<string, unknown> | undefined
return info?.role === "user"
})
const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "user";
});
if (!lastUser) return null
const userInfo = (lastUser as { info?: Record<string, unknown> }).info
const userMessageID = userInfo?.id as string | undefined
if (!userMessageID) return null
if (!lastUser) return null;
const userInfo = (lastUser as { info?: Record<string, unknown> }).info;
const userMessageID = userInfo?.id as string | undefined;
if (!userMessageID) return null;
let assistantMessageID: string | undefined
let assistantMessageID: string | undefined;
if (lastAssistant) {
const assistantInfo = (lastAssistant as { info?: Record<string, unknown> }).info
assistantMessageID = assistantInfo?.id as string | undefined
const assistantInfo = (
lastAssistant as { info?: Record<string, unknown> }
).info;
assistantMessageID = assistantInfo?.id as string | undefined;
}
return { userMessageID, assistantMessageID }
return { userMessageID, assistantMessageID };
} catch {
return null
return null;
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
export async function getLastAssistant(
sessionID: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string
directory: string,
): Promise<Record<string, unknown> | null> {
try {
const resp = await (client as Client).session.messages({
path: { id: sessionID },
query: { directory },
})
});
const data = (resp as { data?: unknown[] }).data
if (!Array.isArray(data)) return null
const data = (resp as { data?: unknown[] }).data;
if (!Array.isArray(data)) return null;
const reversed = [...data].reverse()
const reversed = [...data].reverse();
const last = reversed.find((m) => {
const msg = m as Record<string, unknown>
const info = msg.info as Record<string, unknown> | undefined
return info?.role === "assistant"
})
if (!last) return null
return (last as { info?: Record<string, unknown> }).info ?? null
const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "assistant";
});
if (!last) return null;
return (last as { info?: Record<string, unknown> }).info ?? null;
} catch {
return null
return null;
}
}
function clearSessionState(autoCompactState: AutoCompactState, sessionID: string): void {
autoCompactState.pendingCompact.delete(sessionID)
autoCompactState.errorDataBySession.delete(sessionID)
autoCompactState.retryStateBySession.delete(sessionID)
autoCompactState.fallbackStateBySession.delete(sessionID)
autoCompactState.truncateStateBySession.delete(sessionID)
autoCompactState.emptyContentAttemptBySession.delete(sessionID)
autoCompactState.compactionInProgress.delete(sessionID)
function clearSessionState(
autoCompactState: AutoCompactState,
sessionID: string,
): void {
autoCompactState.pendingCompact.delete(sessionID);
autoCompactState.errorDataBySession.delete(sessionID);
autoCompactState.retryStateBySession.delete(sessionID);
autoCompactState.fallbackStateBySession.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID);
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
autoCompactState.compactionInProgress.delete(sessionID);
}
function getOrCreateEmptyContentAttempt(
autoCompactState: AutoCompactState,
sessionID: string
sessionID: string,
): number {
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0;
}
async function fixEmptyMessages(
sessionID: string,
autoCompactState: AutoCompactState,
client: Client,
messageIndex?: number
messageIndex?: number,
): Promise<boolean> {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID);
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1);
let fixed = false
const fixedMessageIds: string[] = []
let fixed = false;
const fixedMessageIds: string[] = [];
if (messageIndex !== undefined) {
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex)
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex);
if (targetMessageId) {
const replaced = replaceEmptyTextParts(targetMessageId, "[user interrupted]")
const replaced = replaceEmptyTextParts(
targetMessageId,
"[user interrupted]",
);
if (replaced) {
fixed = true
fixedMessageIds.push(targetMessageId)
fixed = true;
fixedMessageIds.push(targetMessageId);
} else {
const injected = injectTextPart(sessionID, targetMessageId, "[user interrupted]")
const injected = injectTextPart(
sessionID,
targetMessageId,
"[user interrupted]",
);
if (injected) {
fixed = true
fixedMessageIds.push(targetMessageId)
fixed = true;
fixedMessageIds.push(targetMessageId);
}
}
}
}
if (!fixed) {
const emptyMessageIds = findEmptyMessages(sessionID)
const emptyMessageIds = findEmptyMessages(sessionID);
if (emptyMessageIds.length === 0) {
await client.tui
.showToast({
@@ -211,20 +243,24 @@ async function fixEmptyMessages(
duration: 5000,
},
})
.catch(() => {})
return false
.catch(() => {});
return false;
}
for (const messageID of emptyMessageIds) {
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]")
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]");
if (replaced) {
fixed = true
fixedMessageIds.push(messageID)
fixed = true;
fixedMessageIds.push(messageID);
} else {
const injected = injectTextPart(sessionID, messageID, "[user interrupted]")
const injected = injectTextPart(
sessionID,
messageID,
"[user interrupted]",
);
if (injected) {
fixed = true
fixedMessageIds.push(messageID)
fixed = true;
fixedMessageIds.push(messageID);
}
}
}
@@ -240,10 +276,10 @@ async function fixEmptyMessages(
duration: 3000,
},
})
.catch(() => {})
.catch(() => {});
}
return fixed
return fixed;
}
export async function executeCompact(
@@ -253,15 +289,27 @@ export async function executeCompact(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string,
experimental?: ExperimentalConfig
experimental?: ExperimentalConfig,
): Promise<void> {
if (autoCompactState.compactionInProgress.has(sessionID)) {
return
await (client as Client).tui
.showToast({
body: {
title: "Compact In Progress",
message:
"Recovery already running. Please wait or start new session if stuck.",
variant: "warning",
duration: 5000,
},
})
.catch(() => {});
return;
}
autoCompactState.compactionInProgress.add(sessionID)
autoCompactState.compactionInProgress.add(sessionID);
const errorData = autoCompactState.errorDataBySession.get(sessionID)
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID)
try {
const errorData = autoCompactState.errorDataBySession.get(sessionID);
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
if (
experimental?.aggressive_truncation &&
@@ -274,50 +322,52 @@ export async function executeCompact(
currentTokens: errorData.currentTokens,
maxTokens: errorData.maxTokens,
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
})
});
const aggressiveResult = truncateUntilTargetTokens(
sessionID,
errorData.currentTokens,
errorData.maxTokens,
TRUNCATE_CONFIG.targetTokenRatio,
TRUNCATE_CONFIG.charsPerToken
)
TRUNCATE_CONFIG.charsPerToken,
);
if (aggressiveResult.truncatedCount > 0) {
truncateState.truncateAttempt += aggressiveResult.truncatedCount
truncateState.truncateAttempt += aggressiveResult.truncatedCount;
const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ")
const toolNames = aggressiveResult.truncatedTools
.map((t) => t.toolName)
.join(", ");
const statusMsg = aggressiveResult.sufficient
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`;
await (client as Client).tui
.showToast({
body: {
title: aggressiveResult.sufficient ? "Aggressive Truncation" : "Partial Truncation",
title: aggressiveResult.sufficient
? "Aggressive Truncation"
: "Partial Truncation",
message: `${statusMsg}: ${toolNames}`,
variant: "warning",
duration: 4000,
},
})
.catch(() => {})
.catch(() => {});
log("[auto-compact] aggressive truncation completed", aggressiveResult)
log("[auto-compact] aggressive truncation completed", aggressiveResult);
if (aggressiveResult.sufficient) {
autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
})
});
} catch {}
}, 500)
return
}, 500);
return;
}
} else {
await (client as Client).tui
@@ -329,21 +379,24 @@ export async function executeCompact(
duration: 3000,
},
})
.catch(() => {})
.catch(() => {});
}
}
let skipSummarize = false
let skipSummarize = false;
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
const largest = findLargestToolResult(sessionID)
const largest = findLargestToolResult(sessionID);
if (largest && largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate) {
const result = truncateToolResult(largest.partPath)
if (
largest &&
largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate
) {
const result = truncateToolResult(largest.partPath);
if (result.success) {
truncateState.truncateAttempt++
truncateState.lastTruncatedPartId = largest.partId
truncateState.truncateAttempt++;
truncateState.lastTruncatedPartId = largest.partId;
await (client as Client).tui
.showToast({
@@ -354,9 +407,7 @@ export async function executeCompact(
duration: 3000,
},
})
.catch(() => {})
autoCompactState.compactionInProgress.delete(sessionID)
.catch(() => {});
setTimeout(async () => {
try {
@@ -364,13 +415,17 @@ export async function executeCompact(
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
})
});
} catch {}
}, 500)
return
}, 500);
return;
}
} else if (errorData?.currentTokens && errorData?.maxTokens && errorData.currentTokens > errorData.maxTokens) {
skipSummarize = true
} else if (
errorData?.currentTokens &&
errorData?.maxTokens &&
errorData.currentTokens > errorData.maxTokens
) {
skipSummarize = true;
await (client as Client).tui
.showToast({
body: {
@@ -380,7 +435,7 @@ export async function executeCompact(
duration: 3000,
},
})
.catch(() => {})
.catch(() => {});
} else if (!errorData?.currentTokens) {
await (client as Client).tui
.showToast({
@@ -391,56 +446,65 @@ export async function executeCompact(
duration: 3000,
},
})
.catch(() => {})
.catch(() => {});
}
}
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
if (errorData?.errorType?.includes("non-empty content")) {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
const attempt = getOrCreateEmptyContentAttempt(
autoCompactState,
sessionID,
);
if (attempt < 3) {
const fixed = await fixEmptyMessages(
sessionID,
autoCompactState,
client as Client,
errorData.messageIndex
)
errorData.messageIndex,
);
if (fixed) {
autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
}, 500)
return
executeCompact(
sessionID,
msg,
autoCompactState,
client,
directory,
experimental,
);
}, 500);
return;
}
} else {
await (client as Client).tui
.showToast({
body: {
title: "Recovery Failed",
message: "Max recovery attempts (3) reached for empty content error. Please start a new session.",
message:
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
variant: "error",
duration: 10000,
},
})
.catch(() => {})
autoCompactState.compactionInProgress.delete(sessionID)
return
.catch(() => {});
return;
}
}
if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0
autoCompactState.fallbackStateBySession.delete(sessionID)
autoCompactState.truncateStateBySession.delete(sessionID)
retryState.attempt = 0;
autoCompactState.fallbackStateBySession.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID);
}
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
retryState.attempt++
retryState.lastAttemptTime = Date.now()
retryState.attempt++;
retryState.lastAttemptTime = Date.now();
const providerID = msg.providerID as string | undefined
const modelID = msg.modelID as string | undefined
const providerID = msg.providerID as string | undefined;
const modelID = msg.modelID as string | undefined;
if (providerID && modelID) {
try {
@@ -453,15 +517,13 @@ export async function executeCompact(
duration: 3000,
},
})
.catch(() => {})
.catch(() => {});
await (client as Client).session.summarize({
path: { id: sessionID },
body: { providerID, modelID },
query: { directory },
})
autoCompactState.compactionInProgress.delete(sessionID)
});
setTimeout(async () => {
try {
@@ -469,20 +531,27 @@ export async function executeCompact(
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
})
});
} catch {}
}, 500)
return
}, 500);
return;
} catch {
autoCompactState.compactionInProgress.delete(sessionID)
const delay = RETRY_CONFIG.initialDelayMs * Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
const delay =
RETRY_CONFIG.initialDelayMs *
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1);
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs);
setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
}, cappedDelay)
return
executeCompact(
sessionID,
msg,
autoCompactState,
client,
directory,
experimental,
);
}, cappedDelay);
return;
}
} else {
await (client as Client).tui
@@ -494,14 +563,18 @@ export async function executeCompact(
duration: 3000,
},
})
.catch(() => {})
.catch(() => {});
}
}
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID)
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID);
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) {
const pair = await getLastMessagePair(sessionID, client as Client, directory)
const pair = await getLastMessagePair(
sessionID,
client as Client,
directory,
);
if (pair) {
try {
@@ -514,27 +587,27 @@ export async function executeCompact(
duration: 3000,
},
})
.catch(() => {})
.catch(() => {});
if (pair.assistantMessageID) {
await (client as Client).session.revert({
path: { id: sessionID },
body: { messageID: pair.assistantMessageID },
query: { directory },
})
});
}
await (client as Client).session.revert({
path: { id: sessionID },
body: { messageID: pair.userMessageID },
query: { directory },
})
});
fallbackState.revertAttempt++
fallbackState.lastRevertedMessageID = pair.userMessageID
fallbackState.revertAttempt++;
fallbackState.lastRevertedMessageID = pair.userMessageID;
// Clear all state after successful revert - don't recurse
clearSessionState(autoCompactState, sessionID)
clearSessionState(autoCompactState, sessionID);
// Send "Continue" prompt to resume session
setTimeout(async () => {
@@ -543,10 +616,10 @@ export async function executeCompact(
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
})
});
} catch {}
}, 500)
return
}, 500);
return;
} catch {}
} else {
await (client as Client).tui
@@ -558,11 +631,11 @@ export async function executeCompact(
duration: 3000,
},
})
.catch(() => {})
.catch(() => {});
}
}
clearSessionState(autoCompactState, sessionID)
clearSessionState(autoCompactState, sessionID);
await (client as Client).tui
.showToast({
@@ -573,5 +646,8 @@ export async function executeCompact(
duration: 5000,
},
})
.catch(() => {})
.catch(() => {});
} finally {
autoCompactState.compactionInProgress.delete(sessionID);
}
}