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 3b17ee9bd0
commit 06b77643ba
2 changed files with 731 additions and 395 deletions

View File

@@ -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)
})
})

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);
}
}