feat(#61): Implement fallback mechanism for auto-compact token limit recovery

- Add FallbackState interface to track message removal attempts
- Implement getLastMessagePair() to identify last user+assistant message pair
- Add executeRevertFallback() to remove message pairs when compaction fails
- Configure max 3 revert attempts with min 2 messages requirement
- Trigger fallback after 5 compaction retries exceed
- Reset retry counter on successful message removal for fresh compaction attempt
- Clean fallback state on session deletion

Resolves: When massive context (context bomb) is loaded, compaction fails and session becomes completely broken. Now falls back to emergency message removal after all retry attempts fail, allowing session recovery.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-15 21:18:03 +09:00
parent 151ebbf407
commit cea64e40b8
3 changed files with 169 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
import type { AutoCompactState, RetryState } from "./types" import type { AutoCompactState, FallbackState, RetryState } from "./types"
import { RETRY_CONFIG } from "./types" import { FALLBACK_CONFIG, RETRY_CONFIG } from "./types"
type Client = { type Client = {
session: { session: {
@@ -9,6 +9,11 @@ type Client = {
body: { providerID: string; modelID: string } body: { providerID: string; modelID: string }
query: { directory: string } query: { directory: string }
}) => Promise<unknown> }) => Promise<unknown>
revert: (opts: {
path: { id: string }
body: { messageID: string; partID?: string }
query: { directory: string }
}) => Promise<unknown>
} }
tui: { tui: {
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown> submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
@@ -40,6 +45,122 @@ function getOrCreateRetryState(
return state return state
} }
function getOrCreateFallbackState(
autoCompactState: AutoCompactState,
sessionID: string
): FallbackState {
let state = autoCompactState.fallbackStateBySession.get(sessionID)
if (!state) {
state = { revertAttempt: 0 }
autoCompactState.fallbackStateBySession.set(sessionID, state)
}
return state
}
async function getLastMessagePair(
sessionID: string,
client: Client,
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 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 lastUser = reversed.find((m) => {
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
let assistantMessageID: string | undefined
if (lastAssistant) {
const assistantInfo = (lastAssistant as { info?: Record<string, unknown> }).info
assistantMessageID = assistantInfo?.id as string | undefined
}
return { userMessageID, assistantMessageID }
} catch {
return null
}
}
async function executeRevertFallback(
sessionID: string,
autoCompactState: AutoCompactState,
client: Client,
directory: string
): Promise<boolean> {
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID)
if (fallbackState.revertAttempt >= FALLBACK_CONFIG.maxRevertAttempts) {
return false
}
const pair = await getLastMessagePair(sessionID, client, directory)
if (!pair) {
return false
}
await client.tui
.showToast({
body: {
title: "⚠️ Emergency Recovery",
message: `Context too large. Removing last message pair to recover session...`,
variant: "warning",
duration: 4000,
},
})
.catch(() => {})
try {
if (pair.assistantMessageID) {
await client.session.revert({
path: { id: sessionID },
body: { messageID: pair.assistantMessageID },
query: { directory },
})
}
await client.session.revert({
path: { id: sessionID },
body: { messageID: pair.userMessageID },
query: { directory },
})
fallbackState.revertAttempt++
fallbackState.lastRevertedMessageID = pair.userMessageID
const retryState = autoCompactState.retryStateBySession.get(sessionID)
if (retryState) {
retryState.attempt = 0
}
return true
} catch {
return false
}
}
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
@@ -72,6 +193,7 @@ function clearSessionState(autoCompactState: AutoCompactState, sessionID: string
autoCompactState.pendingCompact.delete(sessionID) autoCompactState.pendingCompact.delete(sessionID)
autoCompactState.errorDataBySession.delete(sessionID) autoCompactState.errorDataBySession.delete(sessionID)
autoCompactState.retryStateBySession.delete(sessionID) autoCompactState.retryStateBySession.delete(sessionID)
autoCompactState.fallbackStateBySession.delete(sessionID)
} }
export async function executeCompact( export async function executeCompact(
@@ -85,13 +207,42 @@ export async function executeCompact(
const retryState = getOrCreateRetryState(autoCompactState, sessionID) const retryState = getOrCreateRetryState(autoCompactState, sessionID)
if (!shouldRetry(retryState)) { if (!shouldRetry(retryState)) {
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID)
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) {
const reverted = await executeRevertFallback(
sessionID,
autoCompactState,
client as Client,
directory
)
if (reverted) {
await (client as Client).tui
.showToast({
body: {
title: "Recovery Attempt",
message: "Message removed. Retrying compaction...",
variant: "info",
duration: 3000,
},
})
.catch(() => {})
setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory)
}, 1000)
return
}
}
clearSessionState(autoCompactState, sessionID) clearSessionState(autoCompactState, sessionID)
await (client as Client).tui await (client as Client).tui
.showToast({ .showToast({
body: { body: {
title: "Auto Compact Failed", title: "Auto Compact Failed",
message: `Failed after ${RETRY_CONFIG.maxAttempts} attempts. Please try manual compact.`, message: `Failed after ${RETRY_CONFIG.maxAttempts} retries and ${FALLBACK_CONFIG.maxRevertAttempts} message removals. Please start a new session.`,
variant: "error", variant: "error",
duration: 5000, duration: 5000,
}, },

View File

@@ -8,6 +8,7 @@ function createAutoCompactState(): AutoCompactState {
pendingCompact: new Set<string>(), pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(), errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map(), retryStateBySession: new Map(),
fallbackStateBySession: new Map(),
} }
} }
@@ -23,6 +24,7 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
autoCompactState.pendingCompact.delete(sessionInfo.id) autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id) autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id) autoCompactState.retryStateBySession.delete(sessionInfo.id)
autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
} }
return return
} }
@@ -120,6 +122,6 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
} }
} }
export type { AutoCompactState, ParsedTokenLimitError } from "./types" export type { AutoCompactState, FallbackState, ParsedTokenLimitError } from "./types"
export { parseAnthropicTokenLimitError } from "./parser" export { parseAnthropicTokenLimitError } from "./parser"
export { executeCompact, getLastAssistant } from "./executor" export { executeCompact, getLastAssistant } from "./executor"

View File

@@ -12,15 +12,26 @@ export interface RetryState {
lastAttemptTime: number lastAttemptTime: number
} }
export interface FallbackState {
revertAttempt: number
lastRevertedMessageID?: string
}
export interface AutoCompactState { export interface AutoCompactState {
pendingCompact: Set<string> pendingCompact: Set<string>
errorDataBySession: Map<string, ParsedTokenLimitError> errorDataBySession: Map<string, ParsedTokenLimitError>
retryStateBySession: Map<string, RetryState> retryStateBySession: Map<string, RetryState>
fallbackStateBySession: Map<string, FallbackState>
} }
export const RETRY_CONFIG = { export const RETRY_CONFIG = {
maxAttempts: 5, maxAttempts: 2,
initialDelayMs: 2000, initialDelayMs: 2000,
backoffFactor: 2, backoffFactor: 2,
maxDelayMs: 30000, maxDelayMs: 30000,
} as const } as const
export const FALLBACK_CONFIG = {
maxRevertAttempts: 3,
minMessagesRequired: 2,
} as const