fix: harden resume after compaction failures (#195)
* fix: harden resume after compaction failures * test: cover resume compaction safeguards * fix: address resume safeguard review findings
This commit is contained in:
@@ -46,6 +46,7 @@ import type { AttributionState } from './utils/commitAttribution.js'
|
|||||||
import { getGlobalConfig } from './utils/config.js'
|
import { getGlobalConfig } from './utils/config.js'
|
||||||
import { getCwd } from './utils/cwd.js'
|
import { getCwd } from './utils/cwd.js'
|
||||||
import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
|
import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
|
||||||
|
import { logForDebugging } from './utils/debug.js'
|
||||||
import { getFastModeState } from './utils/fastMode.js'
|
import { getFastModeState } from './utils/fastMode.js'
|
||||||
import {
|
import {
|
||||||
type FileHistoryState,
|
type FileHistoryState,
|
||||||
@@ -695,9 +696,11 @@ export class QueryEngine {
|
|||||||
// progress are now recorded inline (their switch cases below), but
|
// progress are now recorded inline (their switch cases below), but
|
||||||
// this flush still matters for the preservedSegment tail walk.
|
// this flush still matters for the preservedSegment tail walk.
|
||||||
// If the SDK subprocess restarts before then (claude-desktop kills
|
// If the SDK subprocess restarts before then (claude-desktop kills
|
||||||
// between turns), tailUuid points to a never-written message →
|
// between turns), tailUuid can point to a never-written message. In
|
||||||
// applyPreservedSegmentRelinks fails its tail→head walk → returns
|
// that case strip preservedSegment before transcript persistence so
|
||||||
// without pruning → resume loads full pre-compact history.
|
// resume falls back to ordinary boundary pruning instead of relying on
|
||||||
|
// broken relink metadata.
|
||||||
|
let transcriptMessage = message
|
||||||
if (
|
if (
|
||||||
persistSession &&
|
persistSession &&
|
||||||
message.type === 'system' &&
|
message.type === 'system' &&
|
||||||
@@ -710,10 +713,21 @@ export class QueryEngine {
|
|||||||
)
|
)
|
||||||
if (tailIdx !== -1) {
|
if (tailIdx !== -1) {
|
||||||
await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
|
await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
|
||||||
|
} else {
|
||||||
|
transcriptMessage = {
|
||||||
|
...message,
|
||||||
|
compactMetadata: {
|
||||||
|
...message.compactMetadata,
|
||||||
|
preservedSegment: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
logForDebugging(
|
||||||
|
`[QueryEngine] stripped preservedSegment before transcript write; missing tail ${tailUuid}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messages.push(message)
|
messages.push(transcriptMessage)
|
||||||
if (persistSession) {
|
if (persistSession) {
|
||||||
// Fire-and-forget for assistant messages. claude.ts yields one
|
// Fire-and-forget for assistant messages. claude.ts yields one
|
||||||
// assistant message per content block, then mutates the last
|
// assistant message per content block, then mutates the last
|
||||||
|
|||||||
@@ -3137,7 +3137,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
logError(error);
|
logError(error);
|
||||||
process.exit(1);
|
return await exitWithError(root, errorMessage(error), () => gracefulShutdown(1));
|
||||||
}
|
}
|
||||||
} else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) {
|
} else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) {
|
||||||
// `claude connect <url>` — full interactive TUI connected to a remote server
|
// `claude connect <url>` — full interactive TUI connected to a remote server
|
||||||
@@ -3644,7 +3644,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
logError(error);
|
logError(error);
|
||||||
await exitWithError(root, `Unable to load transcript from file: ${options.resume}`, () => gracefulShutdown(1));
|
await exitWithError(root, errorMessage(error), () => gracefulShutdown(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3686,7 +3686,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
logError(error);
|
logError(error);
|
||||||
await exitWithError(root, `Failed to resume session ${sessionId}`);
|
await exitWithError(root, errorMessage(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { renameRecordingForSession } from '../utils/asciicast.js';
|
|||||||
import { updateSessionName } from '../utils/concurrentSessions.js';
|
import { updateSessionName } from '../utils/concurrentSessions.js';
|
||||||
import { loadConversationForResume } from '../utils/conversationRecovery.js';
|
import { loadConversationForResume } from '../utils/conversationRecovery.js';
|
||||||
import { checkCrossProjectResume } from '../utils/crossProjectResume.js';
|
import { checkCrossProjectResume } from '../utils/crossProjectResume.js';
|
||||||
|
import { errorMessage } from '../utils/errors.js';
|
||||||
import type { FileHistorySnapshot } from '../utils/fileHistory.js';
|
import type { FileHistorySnapshot } from '../utils/fileHistory.js';
|
||||||
import { logError } from '../utils/log.js';
|
import { logError } from '../utils/log.js';
|
||||||
import { createSystemMessage } from '../utils/messages.js';
|
import { createSystemMessage } from '../utils/messages.js';
|
||||||
@@ -101,6 +102,7 @@ export function ResumeConversation({
|
|||||||
agentColor?: AgentColorName;
|
agentColor?: AgentColorName;
|
||||||
mainThreadAgentDefinition?: AgentDefinition;
|
mainThreadAgentDefinition?: AgentDefinition;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [resumeError, setResumeError] = React.useState<string | null>(null);
|
||||||
const [crossProjectCommand, setCrossProjectCommand] = React.useState<string | null>(null);
|
const [crossProjectCommand, setCrossProjectCommand] = React.useState<string | null>(null);
|
||||||
const sessionLogResultRef = React.useRef<SessionLogResult | null>(null);
|
const sessionLogResultRef = React.useRef<SessionLogResult | null>(null);
|
||||||
// Mirror of logs.length so loadMoreLogs can compute value indices outside
|
// Mirror of logs.length so loadMoreLogs can compute value indices outside
|
||||||
@@ -176,6 +178,7 @@ export function ResumeConversation({
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
async function onSelect(log_0: LogOption) {
|
async function onSelect(log_0: LogOption) {
|
||||||
|
setResumeError(null);
|
||||||
setResuming(true);
|
setResuming(true);
|
||||||
const resumeStart = performance.now();
|
const resumeStart = performance.now();
|
||||||
const crossProjectCheck = checkCrossProjectResume(log_0, showAllProjects, worktreePaths);
|
const crossProjectCheck = checkCrossProjectResume(log_0, showAllProjects, worktreePaths);
|
||||||
@@ -287,7 +290,8 @@ export function ResumeConversation({
|
|||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
logError(e as Error);
|
logError(e as Error);
|
||||||
throw e;
|
setResumeError(errorMessage(e));
|
||||||
|
setResuming(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (crossProjectCommand) {
|
if (crossProjectCommand) {
|
||||||
@@ -308,10 +312,18 @@ export function ResumeConversation({
|
|||||||
<Text> Resuming conversation…</Text>
|
<Text> Resuming conversation…</Text>
|
||||||
</Box>;
|
</Box>;
|
||||||
}
|
}
|
||||||
|
const resumeErrorBanner = resumeError ? <Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="red">Failed to resume conversation.</Text>
|
||||||
|
<Text>{resumeError}</Text>
|
||||||
|
<Text dimColor={true}>Choose a different conversation to continue.</Text>
|
||||||
|
</Box> : null;
|
||||||
if (filteredLogs.length === 0) {
|
if (filteredLogs.length === 0) {
|
||||||
return <NoConversationsMessage />;
|
return <NoConversationsMessage />;
|
||||||
}
|
}
|
||||||
return <LogSelector logs={filteredLogs} maxHeight={rows} onCancel={onCancel} onSelect={onSelect} onLogsChanged={isResumeWithRenameEnabled ? () => loadLogs(showAllProjects) : undefined} onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />;
|
return <Box flexDirection="column">
|
||||||
|
{resumeErrorBanner}
|
||||||
|
<LogSelector logs={filteredLogs} maxHeight={rows} onCancel={onCancel} onSelect={onSelect} onLogsChanged={isResumeWithRenameEnabled ? () => loadLogs(showAllProjects) : undefined} onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />
|
||||||
|
</Box>;
|
||||||
}
|
}
|
||||||
function NoConversationsMessage() {
|
function NoConversationsMessage() {
|
||||||
const $ = _c(2);
|
const $ = _c(2);
|
||||||
|
|||||||
71
src/utils/conversationRecovery.hooks.test.ts
Normal file
71
src/utils/conversationRecovery.hooks.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Hook-side-effect regression lives in a separate file with no static import of
|
||||||
|
* conversationRecovery so Bun's mock.module can replace sessionStart before
|
||||||
|
* that module is first loaded.
|
||||||
|
*/
|
||||||
|
import { afterEach, expect, mock, test } from 'bun:test'
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
const originalSimple = process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
const sessionId = '00000000-0000-4000-8000-000000001999'
|
||||||
|
const ts = '2026-04-02T00:00:00.000Z'
|
||||||
|
|
||||||
|
function id(n: number): string {
|
||||||
|
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function user(uuid: string, content: string) {
|
||||||
|
return {
|
||||||
|
type: 'user',
|
||||||
|
uuid,
|
||||||
|
parentUuid: null,
|
||||||
|
timestamp: ts,
|
||||||
|
cwd: '/tmp',
|
||||||
|
userType: 'external',
|
||||||
|
sessionId,
|
||||||
|
version: 'test',
|
||||||
|
isSidechain: false,
|
||||||
|
isMeta: false,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonl(entry: unknown): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'openclaude-conversation-recovery-hooks-'))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
const filePath = join(dir, 'resume.jsonl')
|
||||||
|
await writeFile(filePath, `${JSON.stringify(entry)}\n`)
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
mock.restore()
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = originalSimple
|
||||||
|
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loadConversationForResume rejects oversized transcripts before resume hooks run', async () => {
|
||||||
|
delete process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
const hugeContent = 'x'.repeat(8 * 1024 * 1024 + 32 * 1024)
|
||||||
|
const path = await writeJsonl(user(id(3), hugeContent))
|
||||||
|
const hookSpy = mock(() => Promise.resolve([{ type: 'hook' }]))
|
||||||
|
|
||||||
|
mock.module('./sessionStart.js', () => ({
|
||||||
|
processSessionStartHooks: hookSpy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { loadConversationForResume, ResumeTranscriptTooLargeError } = await import(
|
||||||
|
'./conversationRecovery.ts'
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(loadConversationForResume('fixture', path)).rejects.toBeInstanceOf(
|
||||||
|
ResumeTranscriptTooLargeError,
|
||||||
|
)
|
||||||
|
expect(hookSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
79
src/utils/conversationRecovery.test.ts
Normal file
79
src/utils/conversationRecovery.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadConversationForResume,
|
||||||
|
ResumeTranscriptTooLargeError,
|
||||||
|
} from './conversationRecovery.ts'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
const originalSimple = process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
const sessionId = '00000000-0000-4000-8000-000000001999'
|
||||||
|
const ts = '2026-04-02T00:00:00.000Z'
|
||||||
|
|
||||||
|
function id(n: number): string {
|
||||||
|
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function user(uuid: string, content: string) {
|
||||||
|
return {
|
||||||
|
type: 'user',
|
||||||
|
uuid,
|
||||||
|
parentUuid: null,
|
||||||
|
timestamp: ts,
|
||||||
|
cwd: '/tmp',
|
||||||
|
userType: 'external',
|
||||||
|
sessionId,
|
||||||
|
version: 'test',
|
||||||
|
isSidechain: false,
|
||||||
|
isMeta: false,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonl(entry: unknown): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'openclaude-conversation-recovery-'))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
const filePath = join(dir, 'resume.jsonl')
|
||||||
|
await writeFile(filePath, `${JSON.stringify(entry)}\n`)
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = originalSimple
|
||||||
|
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loadConversationForResume accepts a small transcript from jsonl path', async () => {
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||||
|
const path = await writeJsonl(user(id(1), 'hello'))
|
||||||
|
|
||||||
|
const result = await loadConversationForResume('fixture', path)
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result?.sessionId).toBe(sessionId)
|
||||||
|
expect(result?.messages.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loadConversationForResume rejects oversized reconstructed transcripts', async () => {
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||||
|
const hugeContent = 'x'.repeat(8 * 1024 * 1024 + 32 * 1024)
|
||||||
|
const path = await writeJsonl(user(id(2), hugeContent))
|
||||||
|
|
||||||
|
let caught: unknown
|
||||||
|
try {
|
||||||
|
await loadConversationForResume('fixture', path)
|
||||||
|
} catch (error) {
|
||||||
|
caught = error
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(ResumeTranscriptTooLargeError)
|
||||||
|
expect((caught as Error).message).toContain(
|
||||||
|
'Reconstructed transcript is too large to resume safely',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
loadTranscriptFile,
|
loadTranscriptFile,
|
||||||
removeExtraFields,
|
removeExtraFields,
|
||||||
} from './sessionStorage.js'
|
} from './sessionStorage.js'
|
||||||
|
import { jsonStringify } from './slowOperations.js'
|
||||||
import type { ContentReplacementRecord } from './toolResultStorage.js'
|
import type { ContentReplacementRecord } from './toolResultStorage.js'
|
||||||
|
|
||||||
// Dead code elimination: ant-only tool names are conditionally required so
|
// Dead code elimination: ant-only tool names are conditionally required so
|
||||||
@@ -71,6 +72,37 @@ const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS')
|
|||||||
: null
|
: null
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
|
|
||||||
|
// Hard cap for reconstructed resume payloads before REPL boot. 8 MiB keeps
|
||||||
|
// resume bounded well below the multi-GB failure mode we saw while leaving
|
||||||
|
// enough room for normal compacted sessions plus resume hook context.
|
||||||
|
const MAX_RESUME_MESSAGE_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
export class ResumeTranscriptTooLargeError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly bytes: number,
|
||||||
|
readonly maxBytes: number,
|
||||||
|
readonly messageCount: number,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
`Reconstructed transcript is too large to resume safely (${(
|
||||||
|
bytes / (1024 * 1024)
|
||||||
|
).toFixed(1)} MiB > ${(maxBytes / (1024 * 1024)).toFixed(1)} MiB, ${messageCount} messages).`,
|
||||||
|
)
|
||||||
|
this.name = 'ResumeTranscriptTooLargeError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertResumeMessageSize(messages: Message[]): void {
|
||||||
|
const bytes = Buffer.byteLength(jsonStringify(messages), 'utf8')
|
||||||
|
if (bytes > MAX_RESUME_MESSAGE_BYTES) {
|
||||||
|
throw new ResumeTranscriptTooLargeError(
|
||||||
|
bytes,
|
||||||
|
MAX_RESUME_MESSAGE_BYTES,
|
||||||
|
messages.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms legacy attachment types to current types for backward compatibility
|
* Transforms legacy attachment types to current types for backward compatibility
|
||||||
*/
|
*/
|
||||||
@@ -561,11 +593,16 @@ export async function loadConversationForResume(
|
|||||||
const deserialized = deserializeMessagesWithInterruptDetection(messages!)
|
const deserialized = deserializeMessagesWithInterruptDetection(messages!)
|
||||||
messages = deserialized.messages
|
messages = deserialized.messages
|
||||||
|
|
||||||
|
// Reject oversized resumes before running side-effectful resume hooks.
|
||||||
|
assertResumeMessageSize(messages)
|
||||||
|
|
||||||
// Process session start hooks for resume
|
// Process session start hooks for resume
|
||||||
const hookMessages = await processSessionStartHooks('resume', { sessionId })
|
const hookMessages = await processSessionStartHooks('resume', { sessionId })
|
||||||
|
|
||||||
// Append hook messages to the conversation
|
// Append hook messages to the conversation and guard again in case hook
|
||||||
|
// output itself pushes the session over the safe resume limit.
|
||||||
messages.push(...hookMessages)
|
messages.push(...hookMessages)
|
||||||
|
assertResumeMessageSize(messages)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
|
|||||||
196
src/utils/sessionStorage.test.ts
Normal file
196
src/utils/sessionStorage.test.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { buildConversationChain, loadTranscriptFile } from './sessionStorage.ts'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
const sessionId = '00000000-0000-4000-8000-000000000999'
|
||||||
|
const ts = '2026-04-02T00:00:00.000Z'
|
||||||
|
|
||||||
|
function id(n: number): string {
|
||||||
|
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function base(uuid: string, parentUuid: string | null) {
|
||||||
|
return {
|
||||||
|
uuid,
|
||||||
|
parentUuid,
|
||||||
|
timestamp: ts,
|
||||||
|
cwd: '/tmp',
|
||||||
|
userType: 'external',
|
||||||
|
sessionId,
|
||||||
|
version: 'test',
|
||||||
|
isSidechain: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function user(uuid: string, parentUuid: string | null, content: string) {
|
||||||
|
return {
|
||||||
|
...base(uuid, parentUuid),
|
||||||
|
type: 'user',
|
||||||
|
isMeta: false,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistant(uuid: string, parentUuid: string | null, text: string) {
|
||||||
|
return {
|
||||||
|
...base(uuid, parentUuid),
|
||||||
|
type: 'assistant',
|
||||||
|
message: {
|
||||||
|
id: uuid,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
model: 'test-model',
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 1,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactBoundary(
|
||||||
|
uuid: string,
|
||||||
|
parentUuid: string | null,
|
||||||
|
preservedSegment: {
|
||||||
|
headUuid: string
|
||||||
|
anchorUuid: string
|
||||||
|
tailUuid: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...base(uuid, parentUuid),
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'compact_boundary',
|
||||||
|
level: 'info',
|
||||||
|
isMeta: false,
|
||||||
|
content: 'Conversation compacted',
|
||||||
|
compactMetadata: {
|
||||||
|
trigger: 'manual',
|
||||||
|
preTokens: 123,
|
||||||
|
preservedSegment,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonl(entries: unknown[]): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'openclaude-session-storage-'))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
const filePath = join(dir, 'session.jsonl')
|
||||||
|
await writeFile(filePath, `${entries.map(e => JSON.stringify(e)).join('\n')}\n`)
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loadTranscriptFile fails closed when preserved-segment tail is missing', async () => {
|
||||||
|
const oldUser = user(id(1), null, 'old user')
|
||||||
|
const oldAssistant = assistant(id(2), id(1), 'old assistant')
|
||||||
|
const preservedHead = assistant(id(3), id(2), 'preserved head')
|
||||||
|
const boundary = compactBoundary(id(4), id(2), {
|
||||||
|
headUuid: id(3),
|
||||||
|
anchorUuid: id(5),
|
||||||
|
tailUuid: id(30),
|
||||||
|
})
|
||||||
|
const summary = user(id(5), id(4), 'summary')
|
||||||
|
|
||||||
|
const filePath = await writeJsonl([
|
||||||
|
oldUser,
|
||||||
|
oldAssistant,
|
||||||
|
preservedHead,
|
||||||
|
boundary,
|
||||||
|
summary,
|
||||||
|
])
|
||||||
|
|
||||||
|
const { messages } = await loadTranscriptFile(filePath)
|
||||||
|
expect(messages.has(id(1))).toBe(false)
|
||||||
|
expect(messages.has(id(2))).toBe(false)
|
||||||
|
expect(messages.has(id(3))).toBe(false)
|
||||||
|
expect(messages.has(id(4))).toBe(true)
|
||||||
|
expect(messages.has(id(5))).toBe(true)
|
||||||
|
|
||||||
|
const chain = buildConversationChain(messages, messages.get(id(5))!)
|
||||||
|
expect(chain.map(message => message.uuid)).toEqual([id(4), id(5)])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loadTranscriptFile preserves and relinks a valid preserved segment', async () => {
|
||||||
|
const oldUser = user(id(11), null, 'old user')
|
||||||
|
const oldAssistant = assistant(id(12), id(11), 'old assistant')
|
||||||
|
const preservedHead = assistant(id(13), id(12), 'preserved head')
|
||||||
|
const preservedTail = assistant(id(14), id(13), 'preserved tail')
|
||||||
|
const boundary = compactBoundary(id(15), id(12), {
|
||||||
|
headUuid: id(13),
|
||||||
|
anchorUuid: id(16),
|
||||||
|
tailUuid: id(14),
|
||||||
|
})
|
||||||
|
const summary = user(id(16), id(15), 'summary')
|
||||||
|
|
||||||
|
const filePath = await writeJsonl([
|
||||||
|
oldUser,
|
||||||
|
oldAssistant,
|
||||||
|
preservedHead,
|
||||||
|
preservedTail,
|
||||||
|
boundary,
|
||||||
|
summary,
|
||||||
|
])
|
||||||
|
|
||||||
|
const { messages } = await loadTranscriptFile(filePath)
|
||||||
|
expect(messages.has(id(11))).toBe(false)
|
||||||
|
expect(messages.has(id(12))).toBe(false)
|
||||||
|
expect(messages.has(id(13))).toBe(true)
|
||||||
|
expect(messages.has(id(14))).toBe(true)
|
||||||
|
expect(messages.get(id(13))?.parentUuid).toBe(id(16))
|
||||||
|
expect(messages.get(id(14))?.parentUuid).toBe(id(13))
|
||||||
|
|
||||||
|
const chain = buildConversationChain(messages, messages.get(id(14))!)
|
||||||
|
expect(chain.map(message => message.uuid)).toEqual([
|
||||||
|
id(15),
|
||||||
|
id(16),
|
||||||
|
id(13),
|
||||||
|
id(14),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loadTranscriptFile fails closed when preserved-segment anchor is missing', async () => {
|
||||||
|
// Models the case where the compact boundary was written but the post-boundary
|
||||||
|
// summary/anchor message never made it to disk.
|
||||||
|
const oldUser = user(id(21), null, 'old user')
|
||||||
|
const oldAssistant = assistant(id(22), id(21), 'old assistant')
|
||||||
|
const preservedHead = assistant(id(23), id(22), 'preserved head')
|
||||||
|
const preservedTail = assistant(id(24), id(23), 'preserved tail')
|
||||||
|
const boundary = compactBoundary(id(25), id(22), {
|
||||||
|
headUuid: id(23),
|
||||||
|
anchorUuid: id(26),
|
||||||
|
tailUuid: id(24),
|
||||||
|
})
|
||||||
|
|
||||||
|
const filePath = await writeJsonl([
|
||||||
|
oldUser,
|
||||||
|
oldAssistant,
|
||||||
|
preservedHead,
|
||||||
|
preservedTail,
|
||||||
|
boundary,
|
||||||
|
])
|
||||||
|
|
||||||
|
const { messages } = await loadTranscriptFile(filePath)
|
||||||
|
expect(messages.has(id(21))).toBe(false)
|
||||||
|
expect(messages.has(id(22))).toBe(false)
|
||||||
|
expect(messages.has(id(23))).toBe(false)
|
||||||
|
expect(messages.has(id(24))).toBe(false)
|
||||||
|
expect(messages.has(id(25))).toBe(true)
|
||||||
|
|
||||||
|
const chain = buildConversationChain(messages, messages.get(id(25))!)
|
||||||
|
expect(chain.map(message => message.uuid)).toEqual([id(25)])
|
||||||
|
})
|
||||||
@@ -1838,7 +1838,10 @@ export function removeExtraFields(
|
|||||||
*/
|
*/
|
||||||
function applyPreservedSegmentRelinks(
|
function applyPreservedSegmentRelinks(
|
||||||
messages: Map<UUID, TranscriptMessage>,
|
messages: Map<UUID, TranscriptMessage>,
|
||||||
): void {
|
): {
|
||||||
|
relinkFailed: boolean
|
||||||
|
} {
|
||||||
|
let relinkFailed = false
|
||||||
type Seg = NonNullable<
|
type Seg = NonNullable<
|
||||||
SystemCompactBoundaryMessage['compactMetadata']['preservedSegment']
|
SystemCompactBoundaryMessage['compactMetadata']['preservedSegment']
|
||||||
>
|
>
|
||||||
@@ -1863,46 +1866,100 @@ function applyPreservedSegmentRelinks(
|
|||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
// No seg anywhere → no-op. findUnresolvedToolUse etc. read the full map.
|
// No seg anywhere → no-op. findUnresolvedToolUse etc. read the full map.
|
||||||
if (!lastSeg) return
|
if (!lastSeg) return { relinkFailed }
|
||||||
|
|
||||||
// Seg stale (no-seg boundary came after): skip relink, still prune at
|
// Seg stale (no-seg boundary came after): skip relink, still prune at
|
||||||
// absolute — otherwise the stale preserved chain becomes a phantom leaf.
|
// absolute — otherwise the stale preserved chain becomes a phantom leaf.
|
||||||
const segIsLive = lastSegBoundaryIdx === absoluteLastBoundaryIdx
|
const segIsLive = lastSegBoundaryIdx === absoluteLastBoundaryIdx
|
||||||
|
|
||||||
// Validate tail→head BEFORE mutating so malformed metadata is a true
|
// Validate tail→head BEFORE mutating so malformed metadata never keeps
|
||||||
// no-op (walk stops at headUuid, doesn't need the relink to run first).
|
// the full pre-compact history alive on resume. If the walk breaks, mark
|
||||||
|
// the relink as failed and fall through to absolute-boundary pruning.
|
||||||
const preservedUuids = new Set<UUID>()
|
const preservedUuids = new Set<UUID>()
|
||||||
if (segIsLive) {
|
if (segIsLive) {
|
||||||
const walkSeen = new Set<UUID>()
|
const walkSeen = new Set<UUID>()
|
||||||
|
const tailInTranscript = messages.has(lastSeg.tailUuid)
|
||||||
|
const headInTranscript = messages.has(lastSeg.headUuid)
|
||||||
|
const anchorInTranscript = messages.has(lastSeg.anchorUuid)
|
||||||
let cur = messages.get(lastSeg.tailUuid)
|
let cur = messages.get(lastSeg.tailUuid)
|
||||||
let reachedHead = false
|
let reachedHead = false
|
||||||
while (cur && !walkSeen.has(cur.uuid)) {
|
let failureKind:
|
||||||
|
| 'missing_tail'
|
||||||
|
| 'missing_parent'
|
||||||
|
| 'null_parent_before_head'
|
||||||
|
| 'cycle_before_head'
|
||||||
|
| 'missing_anchor' = 'missing_tail'
|
||||||
|
let lastSeenUuid: UUID | undefined
|
||||||
|
let lastSeenType: TranscriptMessage['type'] | undefined
|
||||||
|
let breakParentUuid: UUID | null | undefined
|
||||||
|
|
||||||
|
while (cur) {
|
||||||
|
if (walkSeen.has(cur.uuid)) {
|
||||||
|
failureKind = 'cycle_before_head'
|
||||||
|
break
|
||||||
|
}
|
||||||
walkSeen.add(cur.uuid)
|
walkSeen.add(cur.uuid)
|
||||||
preservedUuids.add(cur.uuid)
|
preservedUuids.add(cur.uuid)
|
||||||
|
lastSeenUuid = cur.uuid
|
||||||
|
lastSeenType = cur.type
|
||||||
if (cur.uuid === lastSeg.headUuid) {
|
if (cur.uuid === lastSeg.headUuid) {
|
||||||
reachedHead = true
|
reachedHead = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
cur = cur.parentUuid ? messages.get(cur.parentUuid) : undefined
|
breakParentUuid = cur.parentUuid
|
||||||
|
if (!breakParentUuid) {
|
||||||
|
failureKind = 'null_parent_before_head'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const next = messages.get(breakParentUuid)
|
||||||
|
if (!next) {
|
||||||
|
failureKind = 'missing_parent'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
}
|
}
|
||||||
if (!reachedHead) {
|
|
||||||
|
if (!reachedHead || !anchorInTranscript) {
|
||||||
|
if (!anchorInTranscript && reachedHead) {
|
||||||
|
failureKind = 'missing_anchor'
|
||||||
|
}
|
||||||
// tail→head walk broke — a UUID in the preserved segment isn't in the
|
// tail→head walk broke — a UUID in the preserved segment isn't in the
|
||||||
// transcript. Returning here skips the prune below, so resume loads
|
// transcript. Fail closed: keep only the post-boundary chain instead of
|
||||||
// the full pre-compact history. Known cause: mid-turn-yielded
|
// loading the full pre-compact history on resume.
|
||||||
// attachment pushed to mutableMessages but never recordTranscript'd
|
relinkFailed = true
|
||||||
// (SDK subprocess restarted before next turn's qe:420 flush).
|
preservedUuids.clear()
|
||||||
logEvent('tengu_relink_walk_broken', {
|
logEvent('tengu_relink_walk_broken', {
|
||||||
tailInTranscript: messages.has(lastSeg.tailUuid),
|
failureKind:
|
||||||
headInTranscript: messages.has(lastSeg.headUuid),
|
failureKind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
anchorInTranscript: messages.has(lastSeg.anchorUuid),
|
tailInTranscript,
|
||||||
|
headInTranscript,
|
||||||
|
anchorInTranscript,
|
||||||
|
walkSteps: walkSeen.size,
|
||||||
|
transcriptSize: messages.size,
|
||||||
|
tailIndex: entryIndex.get(lastSeg.tailUuid),
|
||||||
|
headIndex: entryIndex.get(lastSeg.headUuid),
|
||||||
|
anchorIndex: entryIndex.get(lastSeg.anchorUuid),
|
||||||
|
lastSeenType,
|
||||||
|
breakParentInTranscript: Boolean(
|
||||||
|
breakParentUuid && messages.has(breakParentUuid),
|
||||||
|
),
|
||||||
|
breakParentIsNull: breakParentUuid === null,
|
||||||
|
})
|
||||||
|
logForDiagnosticsNoPII('warn', 'relink_walk_broken', {
|
||||||
|
failureKind,
|
||||||
|
tailInTranscript,
|
||||||
|
headInTranscript,
|
||||||
|
anchorInTranscript,
|
||||||
walkSteps: walkSeen.size,
|
walkSteps: walkSeen.size,
|
||||||
transcriptSize: messages.size,
|
transcriptSize: messages.size,
|
||||||
})
|
})
|
||||||
return
|
logForDebugging(
|
||||||
|
`[sessionStorage] preserved-segment relink failed: kind=${failureKind} tail=${lastSeg.tailUuid} head=${lastSeg.headUuid} anchor=${lastSeg.anchorUuid} lastSeen=${lastSeenUuid ?? 'none'} breakParent=${breakParentUuid ?? 'null'}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segIsLive) {
|
if (segIsLive && !relinkFailed) {
|
||||||
const head = messages.get(lastSeg.headUuid)
|
const head = messages.get(lastSeg.headUuid)
|
||||||
if (head) {
|
if (head) {
|
||||||
messages.set(lastSeg.headUuid, {
|
messages.set(lastSeg.headUuid, {
|
||||||
@@ -1953,6 +2010,7 @@ function applyPreservedSegmentRelinks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const uuid of toDelete) messages.delete(uuid)
|
for (const uuid of toDelete) messages.delete(uuid)
|
||||||
|
return { relinkFailed }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3701,7 +3759,12 @@ export async function loadTranscriptFile(
|
|||||||
// File doesn't exist or can't be read
|
// File doesn't exist or can't be read
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPreservedSegmentRelinks(messages)
|
const { relinkFailed } = applyPreservedSegmentRelinks(messages)
|
||||||
|
if (relinkFailed) {
|
||||||
|
logForDiagnosticsNoPII('warn', 'resume_relink_fail_closed', {
|
||||||
|
transcriptSize: messages.size,
|
||||||
|
})
|
||||||
|
}
|
||||||
applySnipRemovals(messages)
|
applySnipRemovals(messages)
|
||||||
|
|
||||||
// Compute leaf UUIDs once at load time
|
// Compute leaf UUIDs once at load time
|
||||||
|
|||||||
Reference in New Issue
Block a user