mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-31 02:06:21 +00:00
fix(core): microcompact hook continuations (#4840)
* fix(core): microcompact hook continuations * test(core): cover hook microcompaction edge cases * test(core): cover hook checkpoint fallback * chore(core): refresh hook microcompaction checks * fix(core): keep hook microcompaction cleanup best-effort * test(core): cover hook microcompaction checkpoint state * test(core): cover hook microcompaction review cases * fix(core): retain hook checkpoint after microcompaction failure * fix(core): clarify hook microcompaction comment * fix(core): preserve hook checkpoint on cron no-op
This commit is contained in:
parent
5c54a2cf8e
commit
53349a0b2e
7 changed files with 407 additions and 93 deletions
|
|
@ -165,7 +165,10 @@ export function extensionConsentString(
|
|||
output.push(
|
||||
t('Installing extension "{{name}}".', { name: extensionConfig.name }),
|
||||
);
|
||||
if (typeof extensionConfig.description === 'string' && extensionConfig.description) {
|
||||
if (
|
||||
typeof extensionConfig.description === 'string' &&
|
||||
extensionConfig.description
|
||||
) {
|
||||
output.push(stripAnsi(extensionConfig.description));
|
||||
}
|
||||
output.push(
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ export function extensionToOutputString(
|
|||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`;
|
||||
if (typeof extension.config.description === 'string' && extension.config.description) {
|
||||
if (
|
||||
typeof extension.config.description === 'string' &&
|
||||
extension.config.description
|
||||
) {
|
||||
output += `\n ${t('Description:')} ${stripAnsi(extension.config.description)}`;
|
||||
}
|
||||
output += `\n ${t('Path:')} ${extension.path}`;
|
||||
|
|
|
|||
|
|
@ -2450,7 +2450,8 @@ export const useGeminiStream = (
|
|||
}
|
||||
|
||||
for (const toolCall of restorableToolCalls) {
|
||||
const filePath = (toolCall.request.args['file_path'] ?? toolCall.request.args['notebook_path']) as string;
|
||||
const filePath = (toolCall.request.args['file_path'] ??
|
||||
toolCall.request.args['notebook_path']) as string;
|
||||
if (!filePath) {
|
||||
onDebugMessage(
|
||||
`Skipping restorable tool call due to missing file_path: ${toolCall.request.name}`,
|
||||
|
|
@ -2501,14 +2502,7 @@ export const useGeminiStream = (
|
|||
}
|
||||
};
|
||||
saveRestorableToolCalls();
|
||||
}, [
|
||||
toolCalls,
|
||||
config,
|
||||
onDebugMessage,
|
||||
history,
|
||||
geminiClient,
|
||||
storage,
|
||||
]);
|
||||
}, [toolCalls, config, onDebugMessage, history, geminiClient, storage]);
|
||||
|
||||
// ─── Unified notification queue (cron + background agents) ──────
|
||||
const notificationQueueRef = useRef<
|
||||
|
|
|
|||
|
|
@ -3551,8 +3551,9 @@ describe('Model Switching and Config Updates', () => {
|
|||
}
|
||||
|
||||
it('resolves getters to the runtime view inside the frame, instance fields outside', async () => {
|
||||
const { runWithRuntimeContentGenerator } =
|
||||
await import('../agents/runtime/agent-context.js');
|
||||
const { runWithRuntimeContentGenerator } = await import(
|
||||
'../agents/runtime/agent-context.js'
|
||||
);
|
||||
const config = new Config(baseParams);
|
||||
const parentGenerator = {
|
||||
generateContentStream: vi.fn(),
|
||||
|
|
@ -3599,8 +3600,9 @@ describe('Model Switching and Config Updates', () => {
|
|||
});
|
||||
|
||||
it('falls back to the parent model id when the runtime view config has no model', async () => {
|
||||
const { runWithRuntimeContentGenerator } =
|
||||
await import('../agents/runtime/agent-context.js');
|
||||
const { runWithRuntimeContentGenerator } = await import(
|
||||
'../agents/runtime/agent-context.js'
|
||||
);
|
||||
const config = new Config(baseParams);
|
||||
setInstanceFields(
|
||||
config,
|
||||
|
|
|
|||
|
|
@ -1588,6 +1588,14 @@ describe('Gemini Client (client.ts)', () => {
|
|||
await client.resetChat();
|
||||
expect(client['lastInjectedDate']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resets Hook microcompaction checkpoint', async () => {
|
||||
client['lastHookMicrocompactionTimestamp'] = Date.now();
|
||||
|
||||
await client.resetChat();
|
||||
|
||||
expect(client['lastHookMicrocompactionTimestamp']).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('history mutation invalidates FileReadCache', () => {
|
||||
|
|
@ -1829,6 +1837,25 @@ describe('Gemini Client (client.ts)', () => {
|
|||
|
||||
expect(client['lastApiCompletionTimestamp']).toBeNull();
|
||||
});
|
||||
|
||||
it('seeds Hook microcompaction checkpoint on user turns', async () => {
|
||||
client['lastHookMicrocompactionTimestamp'] = null;
|
||||
const before = Date.now();
|
||||
|
||||
const gen = client.sendMessageStream(
|
||||
[{ text: 'Hello' }],
|
||||
new AbortController().signal,
|
||||
'prompt-hook-seed',
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
for await (const _ of gen) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThanOrEqual(
|
||||
before,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('microcompaction FileReadCache invalidation', () => {
|
||||
|
|
@ -1924,6 +1951,246 @@ describe('Gemini Client (client.ts)', () => {
|
|||
expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not abort the turn when microcompaction cleanup fails', async () => {
|
||||
const { markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
markReadEvictedFromHistory.mockImplementation(() => {
|
||||
throw new Error('cache disarm failed');
|
||||
});
|
||||
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
client['chat'] = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue(history),
|
||||
setHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000;
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [];
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'hi' }],
|
||||
new AbortController().signal,
|
||||
'prompt-mc-error-boundary',
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: GeminiEventType.Content, value: 'response' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('microcompacts old tool results on Hook continuations', async () => {
|
||||
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
const setHistory = vi.fn();
|
||||
client['chat'] = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue(history),
|
||||
setHistory,
|
||||
} as unknown as GeminiChat;
|
||||
client['lastApiCompletionTimestamp'] = Date.now();
|
||||
client['lastHookMicrocompactionTimestamp'] = Date.now() - 90 * 60_000;
|
||||
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'continue goal' }],
|
||||
new AbortController().signal,
|
||||
'prompt-mc-hook',
|
||||
{ type: SendMessageType.Hook },
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(setHistory).toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1);
|
||||
expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThan(
|
||||
Date.now() - 60_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not abort Hook continuations when microcompaction cleanup fails', async () => {
|
||||
const { markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
markReadEvictedFromHistory.mockImplementation(() => {
|
||||
throw new Error('hook cache disarm failed');
|
||||
});
|
||||
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
client['chat'] = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue(history),
|
||||
setHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
client['lastApiCompletionTimestamp'] = Date.now();
|
||||
const checkpoint = Date.now() - 90 * 60_000;
|
||||
client['lastHookMicrocompactionTimestamp'] = checkpoint;
|
||||
mockClientDebugLogger.error.mockClear();
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [];
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'continue goal' }],
|
||||
new AbortController().signal,
|
||||
'prompt-mc-hook-error-boundary',
|
||||
{ type: SendMessageType.Hook },
|
||||
);
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: GeminiEventType.Content, value: 'response' },
|
||||
]);
|
||||
expect(mockClientDebugLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'microcompactHistory failed: hook cache disarm failed',
|
||||
),
|
||||
);
|
||||
expect(client['lastHookMicrocompactionTimestamp']).toBe(checkpoint);
|
||||
});
|
||||
|
||||
it('skips the next Hook microcompaction after one just ran', async () => {
|
||||
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
const setHistory = vi.fn();
|
||||
client['chat'] = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue(history),
|
||||
setHistory,
|
||||
} as unknown as GeminiChat;
|
||||
client['lastApiCompletionTimestamp'] = Date.now();
|
||||
client['lastHookMicrocompactionTimestamp'] = Date.now() - 90 * 60_000;
|
||||
|
||||
const firstStream = client.sendMessageStream(
|
||||
[{ text: 'continue goal' }],
|
||||
new AbortController().signal,
|
||||
'prompt-mc-hook-fire',
|
||||
{ type: SendMessageType.Hook },
|
||||
);
|
||||
for await (const _ of firstStream) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
const checkpointAfterFire = client['lastHookMicrocompactionTimestamp'];
|
||||
expect(setHistory).toHaveBeenCalled();
|
||||
expect(checkpointAfterFire).toBeGreaterThan(Date.now() - 60_000);
|
||||
|
||||
setHistory.mockClear();
|
||||
clear.mockClear();
|
||||
markReadEvictedFromHistory.mockClear();
|
||||
|
||||
const secondStream = client.sendMessageStream(
|
||||
[{ text: 'continue goal again' }],
|
||||
new AbortController().signal,
|
||||
'prompt-mc-hook-skip',
|
||||
{ type: SendMessageType.Hook },
|
||||
);
|
||||
for await (const _ of secondStream) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(client['lastHookMicrocompactionTimestamp']).toBe(
|
||||
checkpointAfterFire,
|
||||
);
|
||||
expect(setHistory).not.toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(markReadEvictedFromHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initializes Hook microcompaction from the last API completion timestamp', async () => {
|
||||
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
const setHistory = vi.fn();
|
||||
client['chat'] = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue(history),
|
||||
setHistory,
|
||||
} as unknown as GeminiChat;
|
||||
client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000;
|
||||
client['lastHookMicrocompactionTimestamp'] = null;
|
||||
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'continue goal' }],
|
||||
new AbortController().signal,
|
||||
'prompt-mc-hook-init',
|
||||
{ type: SendMessageType.Hook },
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(setHistory).toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1);
|
||||
expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThan(
|
||||
Date.now() - 60_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not microcompact Hook continuations when the checkpoint is recent', async () => {
|
||||
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
const setHistory = vi.fn();
|
||||
client['chat'] = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue(history),
|
||||
setHistory,
|
||||
} as unknown as GeminiChat;
|
||||
client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000;
|
||||
client['lastHookMicrocompactionTimestamp'] = Date.now();
|
||||
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'continue goal' }],
|
||||
new AbortController().signal,
|
||||
'prompt-mc-hook-recent',
|
||||
{ type: SendMessageType.Hook },
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(setHistory).not.toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(markReadEvictedFromHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds Hook microcompaction checkpoint to now when no API call completed', async () => {
|
||||
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
const setHistory = vi.fn();
|
||||
client['chat'] = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue(history),
|
||||
setHistory,
|
||||
} as unknown as GeminiChat;
|
||||
client['lastApiCompletionTimestamp'] = null;
|
||||
client['lastHookMicrocompactionTimestamp'] = null;
|
||||
const before = Date.now();
|
||||
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'continue goal' }],
|
||||
new AbortController().signal,
|
||||
'prompt-mc-hook-no-api-completion',
|
||||
{ type: SendMessageType.Hook },
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThanOrEqual(
|
||||
before,
|
||||
);
|
||||
expect(setHistory).not.toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(markReadEvictedFromHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a blanket clear when blanked reads cannot be linked to a path (id-less provider)', async () => {
|
||||
// Provider did not populate functionCall.id, so microcompaction
|
||||
// cannot recover the blanked reads' file paths. Leaving their
|
||||
|
|
@ -2240,6 +2507,35 @@ describe('Gemini Client (client.ts)', () => {
|
|||
expect(markReadEvictedFromHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reset the Hook checkpoint when Cron skips microcompaction', async () => {
|
||||
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
const setHistory = vi.fn();
|
||||
client['chat'] = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue(history),
|
||||
setHistory,
|
||||
} as unknown as GeminiChat;
|
||||
client['lastApiCompletionTimestamp'] = Date.now();
|
||||
const checkpoint = Date.now() - 90 * 60_000;
|
||||
client['lastHookMicrocompactionTimestamp'] = checkpoint;
|
||||
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'cron job' }],
|
||||
new AbortController().signal,
|
||||
'prompt-cron-hook-checkpoint',
|
||||
{ type: SendMessageType.Cron },
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(client['lastHookMicrocompactionTimestamp']).toBe(checkpoint);
|
||||
expect(setHistory).not.toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(markReadEvictedFromHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run microcompaction on SendMessageType.Retry', async () => {
|
||||
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
|
||||
const { history } = await makeReadFileResponses(6);
|
||||
|
|
|
|||
|
|
@ -224,6 +224,8 @@ export class GeminiClient {
|
|||
* so the idle check is skipped until the first API call completes.
|
||||
*/
|
||||
private lastApiCompletionTimestamp: number | null = null;
|
||||
/** Cleanup checkpoint for long-running Hook continuations such as /goal. */
|
||||
private lastHookMicrocompactionTimestamp: number | null = null;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
this.loopDetector = new LoopDetectionService(config);
|
||||
|
|
@ -589,6 +591,7 @@ export class GeminiClient {
|
|||
this.surfacedRelevantAutoMemoryPaths.clear();
|
||||
this.cachedGitStatus = undefined;
|
||||
this.lastApiCompletionTimestamp = null;
|
||||
this.lastHookMicrocompactionTimestamp = null;
|
||||
// startChat() rewrites the chat to its initial state. Any prior
|
||||
// read_file tool results the FileReadCache still tracks are no
|
||||
// longer in history, so a follow-up Read would serve a placeholder
|
||||
|
|
@ -1328,6 +1331,78 @@ export class GeminiClient {
|
|||
this.toolCallCount += 1;
|
||||
}
|
||||
|
||||
private async microcompactIdleHistory(
|
||||
lastCompletionTimestamp: number | null,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const mcResult = microcompactHistory(
|
||||
this.getHistoryShallow(),
|
||||
lastCompletionTimestamp,
|
||||
this.config.getClearContextOnIdle(),
|
||||
);
|
||||
if (!mcResult.meta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const m = mcResult.meta;
|
||||
this.getChat().setHistory(mcResult.history);
|
||||
// Disarm only the blanked files' fast-path, keeping
|
||||
// read-before-write state intact (issue #4239; rationale on
|
||||
// FileReadEntry.readResidentInHistory). Any blanked read we
|
||||
// can't disarm surgically forces the old blanket wipe so a
|
||||
// later Read can't get a dangling file_unchanged placeholder.
|
||||
const fileReadCache = this.config.getFileReadCache();
|
||||
if (m.unresolvedEvictedReads > 0) {
|
||||
debugLogger.debug(
|
||||
`[FILE_READ_CACHE] clear after microcompaction ` +
|
||||
`(${m.unresolvedEvictedReads} unresolved blanked read(s))`,
|
||||
);
|
||||
fileReadCache.clear();
|
||||
} else {
|
||||
// Concurrent stats — don't serialize N FS round-trips
|
||||
// before the next turn.
|
||||
const statResults = await Promise.all(
|
||||
m.evictedReadPaths.map((p) =>
|
||||
fsPromises.stat(p).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
// A path is surgically disarmed only if it stats AND its
|
||||
// inode matches the recorded entry. A failed stat or inode
|
||||
// miss could leave a stale entry armed, so fall back to the
|
||||
// blanket wipe if any path is unresolvable.
|
||||
let fullyDisarmed = true;
|
||||
for (const stats of statResults) {
|
||||
if (!stats || !fileReadCache.markReadEvictedFromHistory(stats)) {
|
||||
fullyDisarmed = false;
|
||||
}
|
||||
}
|
||||
if (fullyDisarmed) {
|
||||
debugLogger.debug(
|
||||
`[FILE_READ_CACHE] disarmed fast-path for ` +
|
||||
`${m.evictedReadPaths.length} file(s) after microcompaction`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.debug(
|
||||
'[FILE_READ_CACHE] clear after microcompaction ' +
|
||||
'(an evicted path was unresolvable)',
|
||||
);
|
||||
fileReadCache.clear();
|
||||
}
|
||||
}
|
||||
debugLogger.debug(
|
||||
`[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` +
|
||||
`cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` +
|
||||
`kept ${m.toolsKept} tool / ${m.mediaKept} media`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugLogger.error(
|
||||
`[TIME-BASED MC] microcompactHistory failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async *sendMessageStream(
|
||||
request: PartListUnion,
|
||||
signal: AbortSignal,
|
||||
|
|
@ -1527,84 +1602,25 @@ export class GeminiClient {
|
|||
}
|
||||
}
|
||||
|
||||
// Idle cleanup: clear old tool results when idle > threshold.
|
||||
// Runs on UserQuery, Cron, and Hook messages. Hook is required
|
||||
// for goal-mode loops where the model drives continuation without
|
||||
// user input — without this, tool results accumulate indefinitely
|
||||
// and cause OOM (old_space exhaustion).
|
||||
// ToolResult, Retry, Notification are excluded: ToolResult fires
|
||||
// on every tool-call return (O(history) overhead per call), and
|
||||
// mid-loop compaction could blank results the model still needs.
|
||||
const shouldCompact =
|
||||
if (
|
||||
messageType === SendMessageType.UserQuery ||
|
||||
messageType === SendMessageType.Cron ||
|
||||
messageType === SendMessageType.Hook;
|
||||
if (shouldCompact) {
|
||||
try {
|
||||
const mcResult = microcompactHistory(
|
||||
this.getHistoryShallow(),
|
||||
this.lastApiCompletionTimestamp,
|
||||
this.config.getClearContextOnIdle(),
|
||||
);
|
||||
if (mcResult.meta) {
|
||||
const m = mcResult.meta;
|
||||
this.getChat().setHistory(mcResult.history);
|
||||
// Disarm only the blanked files' fast-path, keeping
|
||||
// read-before-write state intact (issue #4239; rationale on
|
||||
// FileReadEntry.readResidentInHistory). Any blanked read we
|
||||
// can't disarm surgically forces the old blanket wipe so a
|
||||
// later Read can't get a dangling file_unchanged placeholder.
|
||||
const fileReadCache = this.config.getFileReadCache();
|
||||
if (m.unresolvedEvictedReads > 0) {
|
||||
debugLogger.debug(
|
||||
`[FILE_READ_CACHE] clear after microcompaction ` +
|
||||
`(${m.unresolvedEvictedReads} unresolved blanked read(s))`,
|
||||
);
|
||||
fileReadCache.clear();
|
||||
} else {
|
||||
// Concurrent stats — don't serialize N FS round-trips
|
||||
// before the next turn.
|
||||
const statResults = await Promise.all(
|
||||
m.evictedReadPaths.map((p) =>
|
||||
fsPromises.stat(p).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
// A path is surgically disarmed only if it stats AND its
|
||||
// inode matches the recorded entry. A failed stat or inode
|
||||
// miss could leave a stale entry armed, so fall back to the
|
||||
// blanket wipe if any path is unresolvable.
|
||||
let fullyDisarmed = true;
|
||||
for (const stats of statResults) {
|
||||
if (
|
||||
!stats ||
|
||||
!fileReadCache.markReadEvictedFromHistory(stats)
|
||||
) {
|
||||
fullyDisarmed = false;
|
||||
}
|
||||
}
|
||||
if (fullyDisarmed) {
|
||||
debugLogger.debug(
|
||||
`[FILE_READ_CACHE] disarmed fast-path for ` +
|
||||
`${m.evictedReadPaths.length} file(s) after microcompaction`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.debug(
|
||||
'[FILE_READ_CACHE] clear after microcompaction ' +
|
||||
'(an evicted path was unresolvable)',
|
||||
);
|
||||
fileReadCache.clear();
|
||||
}
|
||||
}
|
||||
debugLogger.debug(
|
||||
`[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` +
|
||||
`cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` +
|
||||
`kept ${m.toolsKept} tool / ${m.mediaKept} media`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
debugLogger.error(
|
||||
`[TIME-BASED MC] microcompactHistory failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
messageType === SendMessageType.Cron
|
||||
) {
|
||||
// Idle cleanup: clear old tool results when idle > threshold.
|
||||
// Runs on user and cron messages. ToolResult and Retry are
|
||||
// excluded; Hook continuations use a separate checkpoint below.
|
||||
const compacted = await this.microcompactIdleHistory(
|
||||
this.lastApiCompletionTimestamp,
|
||||
);
|
||||
if (messageType === SendMessageType.UserQuery || compacted) {
|
||||
this.lastHookMicrocompactionTimestamp = Date.now();
|
||||
}
|
||||
} else if (messageType === SendMessageType.Hook) {
|
||||
this.lastHookMicrocompactionTimestamp ??=
|
||||
this.lastApiCompletionTimestamp ?? Date.now();
|
||||
const checkpoint = this.lastHookMicrocompactionTimestamp;
|
||||
if (await this.microcompactIdleHistory(checkpoint)) {
|
||||
this.lastHookMicrocompactionTimestamp = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ When the user asks about configuration, the primary reference is `docs/configura
|
|||
| Permissions | `permissions.allow/ask/deny` | `docs/configuration/settings.md`, `docs/features/approval-mode.md` |
|
||||
| MCP Servers | `mcpServers.*`, `mcp.*` | `docs/configuration/settings.md`, `docs/features/mcp.md` |
|
||||
| Tool Approval | `tools.approvalMode` | `docs/configuration/settings.md`, `docs/features/approval-mode.md`, `docs/features/auto-mode.md` |
|
||||
| Hooks | `hooks.*` | `docs/configuration/settings.md`, `docs/features/hooks.md` |
|
||||
| Hooks | `hooks.*` | `docs/configuration/settings.md`, `docs/features/hooks.md` |
|
||||
| Model | `model.name`, `modelProviders` | `docs/configuration/settings.md`, `docs/configuration/model-providers.md` |
|
||||
| General/UI | `general.*`, `ui.*`, `ide.*`, `output.*` | `docs/configuration/settings.md` |
|
||||
| Context | `context.*` | `docs/configuration/settings.md` |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue