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 {
import type { ExperimentalConfig } from "../../config" AutoCompactState,
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types" FallbackState,
import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage" 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 { import {
findEmptyMessages, findEmptyMessages,
findEmptyMessageByIndex, findEmptyMessageByIndex,
injectTextPart, injectTextPart,
replaceEmptyTextParts, replaceEmptyTextParts,
} from "../session-recovery/storage" } from "../session-recovery/storage";
import { log } from "../../shared/logger" import { log } from "../../shared/logger";
type Client = { type Client = {
session: { session: {
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown> messages: (opts: {
path: { id: string };
query?: { directory?: string };
}) => Promise<unknown>;
summarize: (opts: { summarize: (opts: {
path: { id: string } path: { id: string };
body: { providerID: string; modelID: string } body: { providerID: string; modelID: string };
query: { directory: string } query: { directory: string };
}) => Promise<unknown> }) => Promise<unknown>;
revert: (opts: { revert: (opts: {
path: { id: string } path: { id: string };
body: { messageID: string; partID?: string } body: { messageID: string; partID?: string };
query: { directory: string } query: { directory: string };
}) => Promise<unknown> }) => Promise<unknown>;
prompt_async: (opts: { prompt_async: (opts: {
path: { sessionID: string } path: { sessionID: string };
body: { parts: Array<{ type: string; text: string }> } body: { parts: Array<{ type: string; text: string }> };
query: { directory: string } query: { directory: string };
}) => Promise<unknown> }) => Promise<unknown>;
} };
tui: { tui: {
showToast: (opts: { showToast: (opts: {
body: { title: string; message: string; variant: string; duration: number } body: {
}) => Promise<unknown> title: string;
} message: string;
} variant: string;
duration: number;
};
}) => Promise<unknown>;
};
};
function getOrCreateRetryState( function getOrCreateRetryState(
autoCompactState: AutoCompactState, autoCompactState: AutoCompactState,
sessionID: string sessionID: string,
): RetryState { ): RetryState {
let state = autoCompactState.retryStateBySession.get(sessionID) let state = autoCompactState.retryStateBySession.get(sessionID);
if (!state) { if (!state) {
state = { attempt: 0, lastAttemptTime: 0 } state = { attempt: 0, lastAttemptTime: 0 };
autoCompactState.retryStateBySession.set(sessionID, state) autoCompactState.retryStateBySession.set(sessionID, state);
} }
return state return state;
} }
function getOrCreateFallbackState( function getOrCreateFallbackState(
autoCompactState: AutoCompactState, autoCompactState: AutoCompactState,
sessionID: string sessionID: string,
): FallbackState { ): FallbackState {
let state = autoCompactState.fallbackStateBySession.get(sessionID) let state = autoCompactState.fallbackStateBySession.get(sessionID);
if (!state) { if (!state) {
state = { revertAttempt: 0 } state = { revertAttempt: 0 };
autoCompactState.fallbackStateBySession.set(sessionID, state) autoCompactState.fallbackStateBySession.set(sessionID, state);
} }
return state return state;
} }
function getOrCreateTruncateState( function getOrCreateTruncateState(
autoCompactState: AutoCompactState, autoCompactState: AutoCompactState,
sessionID: string sessionID: string,
): TruncateState { ): TruncateState {
let state = autoCompactState.truncateStateBySession.get(sessionID) let state = autoCompactState.truncateStateBySession.get(sessionID);
if (!state) { if (!state) {
state = { truncateAttempt: 0 } state = { truncateAttempt: 0 };
autoCompactState.truncateStateBySession.set(sessionID, state) autoCompactState.truncateStateBySession.set(sessionID, state);
} }
return state return state;
} }
async function getLastMessagePair( async function getLastMessagePair(
sessionID: string, sessionID: string,
client: Client, client: Client,
directory: string directory: string,
): Promise<{ userMessageID: string; assistantMessageID?: string } | null> { ): Promise<{ userMessageID: string; assistantMessageID?: string } | null> {
try { try {
const resp = await client.session.messages({ const resp = await client.session.messages({
path: { id: sessionID }, path: { id: sessionID },
query: { directory }, query: { directory },
}) });
const data = (resp as { data?: unknown[] }).data const data = (resp as { data?: unknown[] }).data;
if (!Array.isArray(data) || data.length < FALLBACK_CONFIG.minMessagesRequired) { if (
return null !Array.isArray(data) ||
data.length < FALLBACK_CONFIG.minMessagesRequired
) {
return null;
} }
const reversed = [...data].reverse() const reversed = [...data].reverse();
const lastAssistant = reversed.find((m) => { const lastAssistant = reversed.find((m) => {
const msg = m as Record<string, unknown> const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "assistant" return info?.role === "assistant";
}) });
const lastUser = reversed.find((m) => { const lastUser = reversed.find((m) => {
const msg = m as Record<string, unknown> const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "user" return info?.role === "user";
}) });
if (!lastUser) return null if (!lastUser) return null;
const userInfo = (lastUser as { info?: Record<string, unknown> }).info const userInfo = (lastUser as { info?: Record<string, unknown> }).info;
const userMessageID = userInfo?.id as string | undefined const userMessageID = userInfo?.id as string | undefined;
if (!userMessageID) return null if (!userMessageID) return null;
let assistantMessageID: string | undefined let assistantMessageID: string | undefined;
if (lastAssistant) { if (lastAssistant) {
const assistantInfo = (lastAssistant as { info?: Record<string, unknown> }).info const assistantInfo = (
assistantMessageID = assistantInfo?.id as string | undefined lastAssistant as { info?: Record<string, unknown> }
).info;
assistantMessageID = assistantInfo?.id as string | undefined;
} }
return { userMessageID, assistantMessageID } return { userMessageID, assistantMessageID };
} catch { } catch {
return null return null;
} }
} }
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B` if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB` return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
} }
export async function getLastAssistant( export async function getLastAssistant(
sessionID: string, sessionID: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any, client: any,
directory: string directory: string,
): Promise<Record<string, unknown> | null> { ): Promise<Record<string, unknown> | null> {
try { try {
const resp = await (client as Client).session.messages({ const resp = await (client as Client).session.messages({
path: { id: sessionID }, path: { id: sessionID },
query: { directory }, query: { directory },
}) });
const data = (resp as { data?: unknown[] }).data const data = (resp as { data?: unknown[] }).data;
if (!Array.isArray(data)) return null if (!Array.isArray(data)) return null;
const reversed = [...data].reverse() const reversed = [...data].reverse();
const last = reversed.find((m) => { const last = reversed.find((m) => {
const msg = m as Record<string, unknown> const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "assistant" return info?.role === "assistant";
}) });
if (!last) return null if (!last) return null;
return (last as { info?: Record<string, unknown> }).info ?? null return (last as { info?: Record<string, unknown> }).info ?? null;
} catch { } catch {
return null return null;
} }
} }
function clearSessionState(autoCompactState: AutoCompactState, sessionID: string): void { function clearSessionState(
autoCompactState.pendingCompact.delete(sessionID) autoCompactState: AutoCompactState,
autoCompactState.errorDataBySession.delete(sessionID) sessionID: string,
autoCompactState.retryStateBySession.delete(sessionID) ): void {
autoCompactState.fallbackStateBySession.delete(sessionID) autoCompactState.pendingCompact.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID) autoCompactState.errorDataBySession.delete(sessionID);
autoCompactState.emptyContentAttemptBySession.delete(sessionID) autoCompactState.retryStateBySession.delete(sessionID);
autoCompactState.compactionInProgress.delete(sessionID) autoCompactState.fallbackStateBySession.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID);
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
autoCompactState.compactionInProgress.delete(sessionID);
} }
function getOrCreateEmptyContentAttempt( function getOrCreateEmptyContentAttempt(
autoCompactState: AutoCompactState, autoCompactState: AutoCompactState,
sessionID: string sessionID: string,
): number { ): number {
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0 return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0;
} }
async function fixEmptyMessages( async function fixEmptyMessages(
sessionID: string, sessionID: string,
autoCompactState: AutoCompactState, autoCompactState: AutoCompactState,
client: Client, client: Client,
messageIndex?: number messageIndex?: number,
): Promise<boolean> { ): Promise<boolean> {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID) const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID);
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1) autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1);
let fixed = false let fixed = false;
const fixedMessageIds: string[] = [] const fixedMessageIds: string[] = [];
if (messageIndex !== undefined) { if (messageIndex !== undefined) {
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex) const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex);
if (targetMessageId) { if (targetMessageId) {
const replaced = replaceEmptyTextParts(targetMessageId, "[user interrupted]") const replaced = replaceEmptyTextParts(
targetMessageId,
"[user interrupted]",
);
if (replaced) { if (replaced) {
fixed = true fixed = true;
fixedMessageIds.push(targetMessageId) fixedMessageIds.push(targetMessageId);
} else { } else {
const injected = injectTextPart(sessionID, targetMessageId, "[user interrupted]") const injected = injectTextPart(
sessionID,
targetMessageId,
"[user interrupted]",
);
if (injected) { if (injected) {
fixed = true fixed = true;
fixedMessageIds.push(targetMessageId) fixedMessageIds.push(targetMessageId);
} }
} }
} }
} }
if (!fixed) { if (!fixed) {
const emptyMessageIds = findEmptyMessages(sessionID) const emptyMessageIds = findEmptyMessages(sessionID);
if (emptyMessageIds.length === 0) { if (emptyMessageIds.length === 0) {
await client.tui await client.tui
.showToast({ .showToast({
@@ -211,20 +243,24 @@ async function fixEmptyMessages(
duration: 5000, duration: 5000,
}, },
}) })
.catch(() => {}) .catch(() => {});
return false return false;
} }
for (const messageID of emptyMessageIds) { for (const messageID of emptyMessageIds) {
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]") const replaced = replaceEmptyTextParts(messageID, "[user interrupted]");
if (replaced) { if (replaced) {
fixed = true fixed = true;
fixedMessageIds.push(messageID) fixedMessageIds.push(messageID);
} else { } else {
const injected = injectTextPart(sessionID, messageID, "[user interrupted]") const injected = injectTextPart(
sessionID,
messageID,
"[user interrupted]",
);
if (injected) { if (injected) {
fixed = true fixed = true;
fixedMessageIds.push(messageID) fixedMessageIds.push(messageID);
} }
} }
} }
@@ -240,10 +276,10 @@ async function fixEmptyMessages(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
} }
return fixed return fixed;
} }
export async function executeCompact( export async function executeCompact(
@@ -253,15 +289,27 @@ export async function executeCompact(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any, client: any,
directory: string, directory: string,
experimental?: ExperimentalConfig experimental?: ExperimentalConfig,
): Promise<void> { ): Promise<void> {
if (autoCompactState.compactionInProgress.has(sessionID)) { 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) try {
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID) const errorData = autoCompactState.errorDataBySession.get(sessionID);
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
if ( if (
experimental?.aggressive_truncation && experimental?.aggressive_truncation &&
@@ -274,50 +322,52 @@ export async function executeCompact(
currentTokens: errorData.currentTokens, currentTokens: errorData.currentTokens,
maxTokens: errorData.maxTokens, maxTokens: errorData.maxTokens,
targetRatio: TRUNCATE_CONFIG.targetTokenRatio, targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
}) });
const aggressiveResult = truncateUntilTargetTokens( const aggressiveResult = truncateUntilTargetTokens(
sessionID, sessionID,
errorData.currentTokens, errorData.currentTokens,
errorData.maxTokens, errorData.maxTokens,
TRUNCATE_CONFIG.targetTokenRatio, TRUNCATE_CONFIG.targetTokenRatio,
TRUNCATE_CONFIG.charsPerToken TRUNCATE_CONFIG.charsPerToken,
) );
if (aggressiveResult.truncatedCount > 0) { 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 const statusMsg = aggressiveResult.sufficient
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})` ? `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 await (client as Client).tui
.showToast({ .showToast({
body: { body: {
title: aggressiveResult.sufficient ? "Aggressive Truncation" : "Partial Truncation", title: aggressiveResult.sufficient
? "Aggressive Truncation"
: "Partial Truncation",
message: `${statusMsg}: ${toolNames}`, message: `${statusMsg}: ${toolNames}`,
variant: "warning", variant: "warning",
duration: 4000, duration: 4000,
}, },
}) })
.catch(() => {}) .catch(() => {});
log("[auto-compact] aggressive truncation completed", aggressiveResult) log("[auto-compact] aggressive truncation completed", aggressiveResult);
if (aggressiveResult.sufficient) { if (aggressiveResult.sufficient) {
autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(async () => { setTimeout(async () => {
try { try {
await (client as Client).session.prompt_async({ await (client as Client).session.prompt_async({
path: { sessionID }, path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] }, body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory }, query: { directory },
}) });
} catch {} } catch {}
}, 500) }, 500);
return return;
} }
} else { } else {
await (client as Client).tui await (client as Client).tui
@@ -329,21 +379,24 @@ export async function executeCompact(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
} }
} }
let skipSummarize = false let skipSummarize = false;
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) { if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
const largest = findLargestToolResult(sessionID) const largest = findLargestToolResult(sessionID);
if (largest && largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate) { if (
const result = truncateToolResult(largest.partPath) largest &&
largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate
) {
const result = truncateToolResult(largest.partPath);
if (result.success) { if (result.success) {
truncateState.truncateAttempt++ truncateState.truncateAttempt++;
truncateState.lastTruncatedPartId = largest.partId truncateState.lastTruncatedPartId = largest.partId;
await (client as Client).tui await (client as Client).tui
.showToast({ .showToast({
@@ -354,9 +407,7 @@ export async function executeCompact(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(async () => { setTimeout(async () => {
try { try {
@@ -364,13 +415,17 @@ export async function executeCompact(
path: { sessionID }, path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] }, body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory }, query: { directory },
}) });
} catch {} } catch {}
}, 500) }, 500);
return return;
} }
} else if (errorData?.currentTokens && errorData?.maxTokens && errorData.currentTokens > errorData.maxTokens) { } else if (
skipSummarize = true errorData?.currentTokens &&
errorData?.maxTokens &&
errorData.currentTokens > errorData.maxTokens
) {
skipSummarize = true;
await (client as Client).tui await (client as Client).tui
.showToast({ .showToast({
body: { body: {
@@ -380,7 +435,7 @@ export async function executeCompact(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
} else if (!errorData?.currentTokens) { } else if (!errorData?.currentTokens) {
await (client as Client).tui await (client as Client).tui
.showToast({ .showToast({
@@ -391,56 +446,65 @@ export async function executeCompact(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
} }
} }
const retryState = getOrCreateRetryState(autoCompactState, sessionID) const retryState = getOrCreateRetryState(autoCompactState, sessionID);
if (errorData?.errorType?.includes("non-empty content")) { if (errorData?.errorType?.includes("non-empty content")) {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID) const attempt = getOrCreateEmptyContentAttempt(
autoCompactState,
sessionID,
);
if (attempt < 3) { if (attempt < 3) {
const fixed = await fixEmptyMessages( const fixed = await fixEmptyMessages(
sessionID, sessionID,
autoCompactState, autoCompactState,
client as Client, client as Client,
errorData.messageIndex errorData.messageIndex,
) );
if (fixed) { if (fixed) {
autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(() => { setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental) executeCompact(
}, 500) sessionID,
return msg,
autoCompactState,
client,
directory,
experimental,
);
}, 500);
return;
} }
} else { } else {
await (client as Client).tui await (client as Client).tui
.showToast({ .showToast({
body: { body: {
title: "Recovery Failed", 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", variant: "error",
duration: 10000, duration: 10000,
}, },
}) })
.catch(() => {}) .catch(() => {});
autoCompactState.compactionInProgress.delete(sessionID) return;
return
} }
} }
if (Date.now() - retryState.lastAttemptTime > 300000) { if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0 retryState.attempt = 0;
autoCompactState.fallbackStateBySession.delete(sessionID) autoCompactState.fallbackStateBySession.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID) autoCompactState.truncateStateBySession.delete(sessionID);
} }
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) { if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
retryState.attempt++ retryState.attempt++;
retryState.lastAttemptTime = Date.now() retryState.lastAttemptTime = Date.now();
const providerID = msg.providerID as string | undefined const providerID = msg.providerID as string | undefined;
const modelID = msg.modelID as string | undefined const modelID = msg.modelID as string | undefined;
if (providerID && modelID) { if (providerID && modelID) {
try { try {
@@ -453,15 +517,13 @@ export async function executeCompact(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
await (client as Client).session.summarize({ await (client as Client).session.summarize({
path: { id: sessionID }, path: { id: sessionID },
body: { providerID, modelID }, body: { providerID, modelID },
query: { directory }, query: { directory },
}) });
autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(async () => { setTimeout(async () => {
try { try {
@@ -469,20 +531,27 @@ export async function executeCompact(
path: { sessionID }, path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] }, body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory }, query: { directory },
}) });
} catch {} } catch {}
}, 500) }, 500);
return return;
} catch { } catch {
autoCompactState.compactionInProgress.delete(sessionID) const delay =
RETRY_CONFIG.initialDelayMs *
const delay = RETRY_CONFIG.initialDelayMs * Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1) Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1);
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs) const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs);
setTimeout(() => { setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental) executeCompact(
}, cappedDelay) sessionID,
return msg,
autoCompactState,
client,
directory,
experimental,
);
}, cappedDelay);
return;
} }
} else { } else {
await (client as Client).tui await (client as Client).tui
@@ -494,14 +563,18 @@ export async function executeCompact(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
} }
} }
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID) const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID);
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) { 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) { if (pair) {
try { try {
@@ -514,27 +587,27 @@ export async function executeCompact(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
if (pair.assistantMessageID) { if (pair.assistantMessageID) {
await (client as Client).session.revert({ await (client as Client).session.revert({
path: { id: sessionID }, path: { id: sessionID },
body: { messageID: pair.assistantMessageID }, body: { messageID: pair.assistantMessageID },
query: { directory }, query: { directory },
}) });
} }
await (client as Client).session.revert({ await (client as Client).session.revert({
path: { id: sessionID }, path: { id: sessionID },
body: { messageID: pair.userMessageID }, body: { messageID: pair.userMessageID },
query: { directory }, query: { directory },
}) });
fallbackState.revertAttempt++ fallbackState.revertAttempt++;
fallbackState.lastRevertedMessageID = pair.userMessageID fallbackState.lastRevertedMessageID = pair.userMessageID;
// Clear all state after successful revert - don't recurse // Clear all state after successful revert - don't recurse
clearSessionState(autoCompactState, sessionID) clearSessionState(autoCompactState, sessionID);
// Send "Continue" prompt to resume session // Send "Continue" prompt to resume session
setTimeout(async () => { setTimeout(async () => {
@@ -543,10 +616,10 @@ export async function executeCompact(
path: { sessionID }, path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] }, body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory }, query: { directory },
}) });
} catch {} } catch {}
}, 500) }, 500);
return return;
} catch {} } catch {}
} else { } else {
await (client as Client).tui await (client as Client).tui
@@ -558,11 +631,11 @@ export async function executeCompact(
duration: 3000, duration: 3000,
}, },
}) })
.catch(() => {}) .catch(() => {});
} }
} }
clearSessionState(autoCompactState, sessionID) clearSessionState(autoCompactState, sessionID);
await (client as Client).tui await (client as Client).tui
.showToast({ .showToast({
@@ -573,5 +646,8 @@ export async function executeCompact(
duration: 5000, duration: 5000,
}, },
}) })
.catch(() => {}) .catch(() => {});
} finally {
autoCompactState.compactionInProgress.delete(sessionID);
}
} }