fix(core): prevent OOM in auto-memory extraction during /quit (#5147) (#5181)

The auto-memory extraction background task caused FATAL ERROR: Reached
heap limit after /quit because buildTranscriptMessages() ran
.replace() over every history message synchronously, but the resulting
text was never consumed downstream.

Changes:
- extract.ts: delete buildTranscriptMessages/loadUnprocessedTranscriptSlice.
  Replace with a zero-stringify cursor scan on the unprocessed slice only.
- manager.ts: add isUnderMemoryPressure() using existing MemoryPressureMonitor.
  Gates in runExtract(), scheduleDream(), and scheduleSkillReview().
- client.ts: add shutdownRequested flag + requestShutdown().
- AppContainer.tsx: call requestShutdown() in /quit callback.
- Tests: 46 passed (11 extract + 34 manager + 2 client).
- Docs: update memory-system.md flowchart to cursor-first flow.

Co-authored-by: 俊良 <zzj542558@alibaba-inc.com>
This commit is contained in:
Zqc 2026-06-19 08:17:45 +08:00 committed by GitHub
parent e911f605d1
commit abfdcc112b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 987 additions and 92 deletions

View file

@ -215,27 +215,28 @@ flowchart TD
```mermaid
flowchart TD
A[runAutoMemoryExtract] --> B[ensureAutoMemoryScaffold\n初始化目录和文件]
B --> C[buildTranscriptMessages\n将 Content[] 转换为带 offset 的消息列表]
C --> D[readExtractCursor\n读取上次处理到的位置]
D --> E[loadUnprocessedTranscriptSlice\n截取未处理的消息段]
E --> F{slice 为空?}
F -- 是 --> G[返回无 patches 结果]
F -- 否 --> H[runAutoMemoryExtractionByAgent\n调用 forked agent 提取 patches]
H --> I[dedupeExtractPatches\n去重+规范化]
I --> J{有 touched topics?}
J -- 是 --> K[bumpMetadata\n更新 meta.json]
K --> L[rebuildManagedAutoMemoryIndex\n重建 MEMORY.md]
L --> M[writeExtractCursor\n记录最新 offset]
J -- 否 --> M
M --> N[返回 AutoMemoryExtractResult]
B --> C[readExtractCursor\n读取上次处理到的位置]
C --> D[history.slice startOffset\n只取未处理的消息切片]
D --> E{slice 有新的 user 消息?}
E -- 否 --> F[更新 cursor\n返回无 patches 结果]
E -- 是 --> G[runAutoMemoryExtractionByAgent\n调用 forked agent 提取]
G --> H{有 touched topics?}
H -- 是 --> I[bumpMetadata\n更新 meta.json]
I --> J[rebuildManagedAutoMemoryIndex\n重建 MEMORY.md]
J --> K[writeExtractCursor\n记录最新 offset = history.length]
H -- 否 --> K
K --> L[返回 AutoMemoryExtractResult]
```
> **注意:** `isUnderMemoryPressure` 门控位于 `MemoryManager.runExtract()` 中,不在本流程内。当 monitor 报告 hard/critical 压力时,`MemoryManager` 会跳过 extract 调用,不推进 cursor。
**提取游标Cursor**
- 字段:`{ sessionId, processedOffset, updatedAt }`
- 每次提取后更新 `processedOffset` 为当前历史长度
- 下次提取时,只处理 `offset >= processedOffset` 的消息
- 提取前先通过 `readExtractCursor` 读取当前进度,再用 `history.slice(processedOffset)` 仅处理未读部分
- 每次提取后更新 `processedOffset` 为当前历史长度(`params.history.length`
- 跨会话时(`sessionId` 变化)从偏移量 0 重新开始
- 注意:不再通过 `buildTranscriptMessages` / `loadUnprocessedTranscriptSlice` 构建转录文本——`hasNewUserMessages` 通过 `history.slice(startOffset).some(m => m.role === 'user' && partToString(m.parts).trim().length > 0)` 判断,仅在未读切片上做轻量字符串化,全量历史不再处理
**Patch 过滤规则**

View file

@ -1155,6 +1155,10 @@ export const AppContainer = (props: AppContainerProps) => {
openApprovalModeDialog,
quit: (messages: HistoryItem[]) => {
setQuittingMessages(messages);
// Signal the client to skip background memory tasks (extract, dream,
// skill review) so the process can exit without spawning new agent
// work during the exit window.
config.getGeminiClient()?.requestShutdown();
setTimeout(async () => {
await runExitCleanup();
process.exit(0);
@ -1206,6 +1210,7 @@ export const AppContainer = (props: AppContainerProps) => {
openDeleteDialog,
openHelpDialog,
openDiffDialog,
config,
],
);

View file

@ -8103,4 +8103,123 @@ Other open files:
});
/* eslint-enable @typescript-eslint/no-explicit-any */
});
describe('#5147 shutdown gate', () => {
/**
* C1: requestShutdown() makes runManagedAutoMemoryBackgroundTasks a
* no-op. We drive the private method directly: before shutdown it
* schedules extract + dream; after shutdown it schedules neither.
*/
it('skips background memory tasks after shutdown is requested', () => {
const scheduleExtractSpy = vi.fn().mockResolvedValue({
touchedTopics: [],
cursor: { sessionId: 'sess', updatedAt: new Date().toISOString() },
});
const scheduleDreamSpy = vi
.fn()
.mockResolvedValue({ status: 'skipped', skippedReason: 'locked' });
const mgr = {
scheduleExtract: scheduleExtractSpy,
scheduleDream: scheduleDreamSpy,
recall: vi.fn(),
scheduleSkillReview: vi
.fn()
.mockReturnValue({ status: 'skipped', skippedReason: 'disabled' }),
};
const client = new GeminiClient(makeMockConfigForShutdown(mgr));
// Avoid needing a real chat — the method calls getHistoryShallow().
(
client as unknown as { getHistoryShallow: () => unknown[] }
).getHistoryShallow = () => [];
const runBgTasks = (
client as unknown as {
runManagedAutoMemoryBackgroundTasks: (t: SendMessageType) => void;
}
).runManagedAutoMemoryBackgroundTasks.bind(client);
// Before shutdown: a completed UserQuery turn schedules extract + dream.
runBgTasks(SendMessageType.UserQuery);
expect(scheduleExtractSpy).toHaveBeenCalledTimes(1);
expect(scheduleDreamSpy).toHaveBeenCalledTimes(1);
scheduleExtractSpy.mockClear();
scheduleDreamSpy.mockClear();
// After shutdown: the gate short-circuits before any scheduling.
client.requestShutdown();
runBgTasks(SendMessageType.UserQuery);
expect(scheduleExtractSpy).not.toHaveBeenCalled();
expect(scheduleDreamSpy).not.toHaveBeenCalled();
});
/**
* C2: requestShutdown() is idempotent calling it multiple times
* should not throw or have side effects.
*/
it('is idempotent when called multiple times', () => {
const mgr = {
scheduleExtract: vi.fn(),
scheduleDream: vi.fn(),
recall: vi.fn(),
scheduleSkillReview: vi
.fn()
.mockReturnValue({ status: 'skipped', skippedReason: 'disabled' }),
};
const cfg = makeMockConfigForShutdown(mgr);
const client = new GeminiClient(cfg);
// Should not throw on first call
expect(() => client.requestShutdown()).not.toThrow();
// Should not throw on second call
expect(() => client.requestShutdown()).not.toThrow();
// Should not throw on third call
expect(() => client.requestShutdown()).not.toThrow();
});
});
});
function makeMockConfigForShutdown(
mgr: Record<string, ReturnType<typeof vi.fn>>,
): Config {
return {
isBareMode: vi.fn().mockReturnValue(false),
getGeminiClient: vi.fn().mockReturnValue(undefined),
getProjectRoot: vi.fn().mockReturnValue('/project'),
getSessionId: vi.fn().mockReturnValue('session-1'),
getMemoryManager: vi.fn().mockReturnValue(mgr),
getManagedAutoMemoryEnabled: vi.fn().mockReturnValue(true),
getManagedAutoDreamEnabled: vi.fn().mockReturnValue(true),
getAutoSkillEnabled: vi.fn().mockReturnValue(false),
getModel: vi.fn().mockReturnValue('test-model'),
getBaseLlmClient: vi.fn().mockReturnValue({
generateContent: vi.fn(),
}),
getContentGenerator: vi.fn().mockReturnValue({
generateContent: vi.fn(),
}),
getToolRegistry: vi.fn().mockReturnValue({
getDeclarations: vi.fn().mockReturnValue([]),
getTools: vi.fn().mockReturnValue([]),
}),
getPromptRegistry: vi.fn().mockReturnValue({
getDeclarations: vi.fn().mockReturnValue([]),
}),
getFileReadCache: vi.fn().mockReturnValue({
clear: vi.fn(),
}),
getExtensionLoader: vi.fn().mockReturnValue(undefined),
getWorkspaceContext: vi.fn().mockReturnValue(undefined),
getDebugMode: vi.fn().mockReturnValue(false),
getApprovalMode: vi.fn().mockReturnValue('default'),
logEvent: vi.fn(),
getTelemetryService: vi.fn().mockReturnValue(undefined),
getHookSystem: vi.fn().mockReturnValue(undefined),
getMaxSessionTurns: vi.fn().mockReturnValue(100),
getChatRecordingService: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(false),
getStdinReader: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
}

View file

@ -211,6 +211,7 @@ export class GeminiClient {
private skillsModifiedInSession = false;
private cachedGitStatus: string | null | undefined;
private readonly surfacedRelevantAutoMemoryPaths = new Set<string>();
private shutdownRequested = false;
private readonly loopDetector: LoopDetectionService;
private lastPromptId: string | undefined = undefined;
@ -596,6 +597,15 @@ export class GeminiClient {
});
}
/**
* Signal that shutdown is imminent. Subsequent calls to background memory
* tasks (extract, dream, skill review) will be skipped so the process can
* exit cleanly without spawning new work.
*/
requestShutdown(): void {
this.shutdownRequested = true;
}
/**
* Abort and release the pending auto-memory prefetch in one step.
* Safe to call when no prefetch is pending does nothing. Centralises
@ -1380,6 +1390,15 @@ export class GeminiClient {
private runManagedAutoMemoryBackgroundTasks(
messageType: SendMessageType,
): void {
// During shutdown, skip all background memory tasks so the process
// can exit cleanly without spawning new work.
if (this.shutdownRequested) {
debugLogger.debug(
'Skipping background memory tasks: shutdown requested.',
);
return;
}
// autoSkill counts tool calls and can trigger on both UserQuery and
// ToolResult turns so the threshold can fire mid-session.
if (

View file

@ -9,12 +9,9 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config } from '../config/config.js';
import type { Content } from '@google/genai';
import { getAutoMemoryExtractCursorPath } from './paths.js';
import {
buildTranscriptMessages,
loadUnprocessedTranscriptSlice,
runAutoMemoryExtract,
} from './extract.js';
import { runAutoMemoryExtract } from './extract.js';
import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js';
import { ensureAutoMemoryScaffold } from './store.js';
import {
@ -57,24 +54,6 @@ describe('auto-memory extraction', () => {
});
});
it('builds transcript slices from history and cursor state', () => {
const transcript = buildTranscriptMessages([
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'world' }] },
{ role: 'user', parts: [{ text: 'I prefer terse responses.' }] },
]);
const slice = loadUnprocessedTranscriptSlice('session-1', transcript, {
sessionId: 'session-1',
processedOffset: 2,
updatedAt: new Date().toISOString(),
});
expect(slice.messages).toHaveLength(1);
expect(slice.messages[0]?.text).toBe('I prefer terse responses.');
expect(slice.nextProcessedOffset).toBe(3);
});
it('updates cursor and avoids duplicate writes for repeated extraction', async () => {
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
touchedTopics: [],
@ -235,4 +214,323 @@ describe('auto-memory extraction', () => {
expect(rebuildUserAutoMemoryIndex).not.toHaveBeenCalled();
});
});
describe('#5147 OOM regression', () => {
/**
* A1: cursor-first runAutoMemoryExtract only processes the unread
* portion of history. The first call processes all messages; the second
* call (with only a few new messages appended) should NOT reprocess
* the already-processed prefix.
*/
it('only processes unread messages via cursor-first ordering', async () => {
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
touchedTopics: ['user'],
touchedProjectScope: true,
touchedUserScope: false,
systemMessage: undefined,
});
// Build 20 messages (10 turns of user+model)
const history: Content[] = [];
for (let i = 0; i < 20; i++) {
history.push({
role: i % 2 === 0 ? 'user' : 'model',
parts: [
{ text: `[MSG${i}] `.padEnd(16, '-') + `content for message ${i}` },
],
});
}
// First extract: processes all 20 messages
const first = await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
expect(first.cursor.processedOffset).toBe(20);
expect(runAutoMemoryExtractionByAgent).toHaveBeenCalledTimes(1);
// Add 2 new messages (1 turn)
history.push(
{ role: 'user', parts: [{ text: 'new user question?' }] },
{ role: 'model', parts: [{ text: 'new assistant answer.' }] },
);
// Second extract: should detect only the 2 new messages
const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock
.calls.length;
const second = await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
// Fork agent should have been called again (new user message found)
expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(
agentCallsBefore + 1,
);
// Cursor advances to full history length
expect(second.cursor.processedOffset).toBe(22);
});
/**
* A2: when the cursor is already at the end of history (no new user
* messages), runAutoMemoryExtract skips without calling the fork agent.
*/
it('skips extract when cursor is already up to date', async () => {
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
touchedTopics: [],
touchedProjectScope: false,
touchedUserScope: false,
systemMessage: undefined,
});
const history: Content[] = [
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'hi' }] },
];
// First extract: cursor → 2
await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock
.calls.length;
// Second extract with same 2 messages: no new user messages
const result = await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
// Fork agent should NOT be called again
expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(
agentCallsBefore,
);
expect(result.touchedTopics).toEqual([]);
expect(result.cursor.processedOffset).toBe(2);
});
/**
* A3: a huge single message does not OOM the cursor scan. The cursor
* path no longer stringifies history with the global whitespace regex;
* it only does a bounded partToString().trim() on the unprocessed slice
* to detect new user content. A 5MB message must be handled without the
* old full-history .replace(/\s+/g) blow-up.
*/
it('handles a huge single message without OOM in the cursor scan', async () => {
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
touchedTopics: ['user'],
touchedProjectScope: true,
touchedUserScope: false,
systemMessage: undefined,
});
const hugeText = 'x '.repeat(2_500_000); // ~5MB with whitespace
const result = await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [{ role: 'user', parts: [{ text: hugeText }] }],
});
// New user content detected → fork agent invoked, cursor advanced.
expect(runAutoMemoryExtractionByAgent).toHaveBeenCalled();
expect(result.cursor.processedOffset).toBe(1);
});
/**
* A4: when the session ID changes between extracts, the cursor from the
* old session is ignored, and the full history is treated as unprocessed.
*/
it('reprocesses full history when session changes', async () => {
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
touchedTopics: ['user'],
touchedProjectScope: true,
touchedUserScope: false,
systemMessage: undefined,
});
const history: Content[] = [
{ role: 'user', parts: [{ text: 'first session query' }] },
{ role: 'model', parts: [{ text: 'first session answer' }] },
];
// Session 1: cursor advances to 2
await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock
.calls.length;
// Session 2: cursor ignored, full history treated as unprocessed
await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-2',
config: mockConfig,
history: [...history],
});
// Fork agent called again (session changed, so messages are "new")
expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(
agentCallsBefore + 1,
);
});
/**
* A5: verify that cursor-first ordering prevents OOM by processing only
* the unread portion. Constructs a large history where most messages
* have already been processed, then verifies that the extract completes
* without processing the full-history text through .replace().
*/
it('avoids full-history regex replace when most messages are already processed', async () => {
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
touchedTopics: ['user'],
touchedProjectScope: true,
touchedUserScope: false,
systemMessage: undefined,
});
// Build 50 messages, each with unique text to prevent string interning.
const history: Content[] = [];
for (let i = 0; i < 50; i++) {
const prefix = `[MSG${i}] `.padEnd(16, '-');
history.push({
role: i % 2 === 0 ? 'user' : 'model',
parts: [{ text: prefix + `${i}: the quick brown fox `.repeat(200) }],
});
}
// Process all 50 in the first extract
await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
// Add 2 more messages — only these need processing
history.push(
{ role: 'user', parts: [{ text: 'final question?'.repeat(50) }] },
{ role: 'model', parts: [{ text: 'final answer.'.repeat(50) }] },
);
const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock
.calls.length;
// This should complete without OOM — it only processes 2 messages,
// not the full 52.
const result = await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(
agentCallsBefore + 1,
);
expect(result.cursor.processedOffset).toBe(52);
});
/**
* A6: the cursor scan must not count empty or whitespace-only user
* messages as "new user content". The partToString().trim().length > 0
* filter should cause the extract to be skipped, just like the old
* buildTranscriptMessages().filter() did.
*/
it('skips extract when unprocessed user messages are whitespace-only', async () => {
const history: Content[] = [{ role: 'user', parts: [{ text: ' ' }] }];
const result = await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
expect(runAutoMemoryExtractionByAgent).not.toHaveBeenCalled();
expect(result.touchedTopics).toEqual([]);
});
it('skips extract when unprocessed user messages have empty parts', async () => {
const history: Content[] = [{ role: 'user', parts: [] }];
const result = await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...history],
});
expect(runAutoMemoryExtractionByAgent).not.toHaveBeenCalled();
expect(result.touchedTopics).toEqual([]);
});
/**
* A7: when history shrinks between extract calls (e.g. compression
* reduces 50 17 entries), the stored processedOffset (50) exceeds
* history.length (17). The cursor-first logic must reset startOffset
* to 0 rather than passing 50 to history.slice(), which would return
* [] and permanently skip new messages.
*/
it('re-scans full history when stored offset exceeds current length (compression)', async () => {
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
touchedTopics: ['user'],
touchedProjectScope: true,
touchedUserScope: false,
systemMessage: undefined,
});
// First extract: 20 messages, cursor advances to 20.
const fullHistory: Content[] = [];
for (let i = 0; i < 20; i++) {
fullHistory.push({
role: i % 2 === 0 ? 'user' : 'model',
parts: [{ text: `compression msg ${i}` }],
});
}
await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...fullHistory],
});
// Simulate compression: history shrinks from 20 to 5, but cursor
// still says processedOffset = 20. Then a new user message is added.
const compressedHistory = fullHistory.slice(0, 5);
compressedHistory.push({
role: 'user',
parts: [{ text: 'new question after compression' }],
});
const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock
.calls.length;
const result = await runAutoMemoryExtract({
projectRoot,
sessionId: 'session-1',
config: mockConfig,
history: [...compressedHistory], // 6 messages, cursor says 20
});
// The new user message must be detected — startOffset was clamped to 0
// instead of using the stale 20 that exceeds history.length (6).
expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(
agentCallsBefore + 1,
);
expect(result.cursor.processedOffset).toBe(compressedHistory.length);
});
});
});

View file

@ -31,50 +31,17 @@ import {
const debugLogger = createDebugLogger('AUTO_MEMORY_EXTRACT');
export interface AutoMemoryTranscriptMessage {
offset: number;
role: 'user' | 'model';
text: string;
}
export interface AutoMemoryExtractResult {
touchedTopics: AutoMemoryType[];
skippedReason?: 'already_running' | 'queued' | 'memory_tool';
skippedReason?:
| 'already_running'
| 'queued'
| 'memory_tool'
| 'memory_pressure';
systemMessage?: string;
cursor: AutoMemoryExtractCursor;
}
export function buildTranscriptMessages(
history: Content[],
): AutoMemoryTranscriptMessage[] {
return history
.map((message, index) => ({
offset: index,
role: message.role,
text: partToString(message.parts ?? [])
.replace(/\s+/g, ' ')
.trim(),
}))
.filter(
(message): message is AutoMemoryTranscriptMessage =>
(message.role === 'user' || message.role === 'model') &&
message.text.length > 0,
);
}
export function loadUnprocessedTranscriptSlice(
sessionId: string,
messages: AutoMemoryTranscriptMessage[],
cursor: AutoMemoryExtractCursor,
): { messages: AutoMemoryTranscriptMessage[]; nextProcessedOffset: number } {
const startOffset =
cursor.sessionId === sessionId ? (cursor.processedOffset ?? 0) : 0;
return {
messages: messages.filter((message) => message.offset >= startOffset),
nextProcessedOffset: messages.length,
};
}
async function readExtractCursor(
projectRoot: string,
): Promise<AutoMemoryExtractCursor> {
@ -153,26 +120,37 @@ export async function runAutoMemoryExtract(params: {
);
}
const transcript = buildTranscriptMessages(params.history);
const currentCursor = await readExtractCursor(params.projectRoot);
const slice = loadUnprocessedTranscriptSlice(
params.sessionId,
transcript,
currentCursor,
);
if (!params.config) {
throw new Error(
'Managed auto-memory extraction requires config for forked-agent execution.',
);
}
// Skip if no new user messages in the unprocessed slice.
const hasNewUserMessages = slice.messages.some((m) => m.role === 'user');
// Read the cursor first, then scan only the unprocessed slice. The old
// code ran partToString().replace() over EVERY message but the resulting
// text was never read — fork agent context comes from getCacheSafeParams().
const currentCursor = await readExtractCursor(params.projectRoot);
const rawOffset =
currentCursor.sessionId === params.sessionId
? (currentCursor.processedOffset ?? 0)
: 0;
// History may shrink between extract calls (compression). Clamp to length
// so new messages after compression are not permanently skipped.
const startOffset = rawOffset > params.history.length ? 0 : rawOffset;
// Skip if there are no new, non-empty user messages in the unprocessed
// slice. partToString runs only on this small slice and without the
// global whitespace regex — the .trim().length check preserves the old
// behaviour of ignoring empty-text user turns.
const hasNewUserMessages = params.history
.slice(startOffset)
.some(
(m) => m.role === 'user' && partToString(m.parts ?? []).trim().length > 0,
);
if (!hasNewUserMessages) {
const cursor: AutoMemoryExtractCursor = {
sessionId: params.sessionId,
processedOffset: slice.nextProcessedOffset,
processedOffset: params.history.length,
updatedAt: now.toISOString(),
};
await writeExtractCursor(params.projectRoot, cursor);
@ -221,7 +199,7 @@ export async function runAutoMemoryExtract(params: {
const cursor: AutoMemoryExtractCursor = {
sessionId: params.sessionId,
processedOffset: slice.nextProcessedOffset,
processedOffset: params.history.length,
updatedAt: now.toISOString(),
};
await writeExtractCursor(params.projectRoot, cursor);

View file

@ -946,4 +946,400 @@ describe('MemoryManager', () => {
});
});
});
// ─── #5147 regression: trailing queue + memory pressure ─────────────────
describe('scheduleExtract #5147', () => {
/**
* B1: When an extract is already running and a new extract is queued,
* superseding the trailing request drops the old params reference (the
* old history becomes GC-eligible). Verify that only the latest params
* are retained and the trailing extract executes correctly.
*/
it('supersedes trailing queue without leaking old history refs', async () => {
vi.mocked(runAutoMemoryExtract).mockClear();
const mgr = new MemoryManager();
let resolveFirst: (
value: Awaited<ReturnType<typeof runAutoMemoryExtract>>,
) => void;
let resolveTrailing: (
value: Awaited<ReturnType<typeof runAutoMemoryExtract>>,
) => void;
const firstPromise = new Promise<
Awaited<ReturnType<typeof runAutoMemoryExtract>>
>((r) => {
resolveFirst = r;
});
const trailingPromise = new Promise<
Awaited<ReturnType<typeof runAutoMemoryExtract>>
>((r) => {
resolveTrailing = r;
});
// First call → starts running
vi.mocked(runAutoMemoryExtract).mockReturnValueOnce(firstPromise);
void mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
history: [
{ role: 'user', parts: [{ text: 'first history' }] },
{ role: 'model', parts: [{ text: 'first response' }] },
],
});
expect(runAutoMemoryExtract).toHaveBeenCalledTimes(1);
// Second call while first is running → queues trailing
const secondResult = await mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
history: [
{ role: 'user', parts: [{ text: 'second history' }] },
{ role: 'model', parts: [{ text: 'second response' }] },
],
});
expect(secondResult.skippedReason).toBe('queued');
// Third call while first is STILL running → supersedes trailing
vi.mocked(runAutoMemoryExtract).mockReturnValueOnce(trailingPromise);
const thirdResult = await mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
history: [
{ role: 'user', parts: [{ text: 'third history' }] },
{ role: 'model', parts: [{ text: 'third response' }] },
],
});
expect(thirdResult.skippedReason).toBe('queued');
// Still only 1 actual extract call (first is still running)
expect(runAutoMemoryExtract).toHaveBeenCalledTimes(1);
// Finish the first extract
resolveFirst!({
touchedTopics: [],
cursor: {
sessionId: 'sess',
processedOffset: 2,
updatedAt: new Date().toISOString(),
},
});
// Wait for the trailing to be picked up and started
await vi.waitFor(() => {
expect(runAutoMemoryExtract).toHaveBeenCalledTimes(2);
});
// Verify the trailing extract received the third call's params,
// not the second call's stale history reference.
expect(runAutoMemoryExtract).toHaveBeenLastCalledWith(
expect.objectContaining({
history: [
{ role: 'user', parts: [{ text: 'third history' }] },
{ role: 'model', parts: [{ text: 'third response' }] },
],
}),
);
// Finish the trailing (should use third history, not second)
resolveTrailing!({
touchedTopics: ['user'],
cursor: {
sessionId: 'sess',
processedOffset: 2,
updatedAt: new Date().toISOString(),
},
});
// Drain to ensure everything settles
await mgr.drain({ timeoutMs: 500 });
});
/**
* B2: extract is skipped with 'memory_pressure' when the shared
* MemoryPressureMonitor reports hard/critical pressure. The cursor is
* NOT advanced (runAutoMemoryExtract is never called), so the unread
* messages are retried on a later, lower-pressure turn.
*/
it('skips extract with memory_pressure when the monitor reports critical', async () => {
vi.mocked(runAutoMemoryExtract).mockClear();
const config = makeMockConfig({
getMemoryPressureMonitor: vi.fn().mockReturnValue({
getPressureLevel: vi.fn().mockReturnValue('critical'),
}),
} as Partial<Config>);
const mgr = new MemoryManager();
const result = await mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
config,
history: [{ role: 'user', parts: [{ text: 'hi' }] }],
});
expect(result.skippedReason).toBe('memory_pressure');
expect(result.touchedTopics).toEqual([]);
// The cursor is deliberately NOT advanced (no processedOffset) so
// unprocessed messages are retried on a later lower-pressure turn.
expect(result.cursor.processedOffset).toBeUndefined();
// Gate fired before invoking the real extract → cursor untouched.
expect(runAutoMemoryExtract).not.toHaveBeenCalled();
});
/**
* B3: extract proceeds normally when the monitor reports normal/soft
* pressure (only hard/critical gate it).
*/
it('does not skip extract when pressure is normal', async () => {
vi.mocked(runAutoMemoryExtract).mockClear();
vi.mocked(runAutoMemoryExtract).mockResolvedValueOnce({
touchedTopics: ['user'],
cursor: {
sessionId: 'sess',
processedOffset: 1,
updatedAt: new Date().toISOString(),
},
});
const config = makeMockConfig({
getMemoryPressureMonitor: vi.fn().mockReturnValue({
getPressureLevel: vi.fn().mockReturnValue('soft'),
}),
} as Partial<Config>);
const mgr = new MemoryManager();
const result = await mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
config,
history: [{ role: 'user', parts: [{ text: 'hi' }] }],
});
expect(result.skippedReason).toBeUndefined();
expect(runAutoMemoryExtract).toHaveBeenCalledTimes(1);
});
/**
* B3c: when getMemoryPressureMonitor() returns undefined, the gate
* allows extraction to proceed the optional-chain returns undefined
* (falsy), so isUnderMemoryPressure returns false.
*/
it('does not skip extract when monitor is absent', async () => {
vi.mocked(runAutoMemoryExtract).mockClear();
vi.mocked(runAutoMemoryExtract).mockResolvedValueOnce({
touchedTopics: ['user'],
cursor: {
sessionId: 'sess',
processedOffset: 1,
updatedAt: new Date().toISOString(),
},
});
const config = makeMockConfig({
getMemoryPressureMonitor: vi.fn().mockReturnValue(undefined),
} as Partial<Config>);
const mgr = new MemoryManager();
const result = await mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
config,
history: [{ role: 'user', parts: [{ text: 'hi' }] }],
});
expect(result.skippedReason).toBeUndefined();
expect(runAutoMemoryExtract).toHaveBeenCalledTimes(1);
});
/**
* B3b: 'hard' pressure level also gates extract (not just 'critical').
* In production 'hard' is the first level to fire as memory climbs, so
* it needs the same coverage as 'critical'.
*/
it('skips extract when monitor reports hard pressure', async () => {
vi.mocked(runAutoMemoryExtract).mockClear();
const config = makeMockConfig({
getMemoryPressureMonitor: vi.fn().mockReturnValue({
getPressureLevel: vi.fn().mockReturnValue('hard'),
}),
} as Partial<Config>);
const mgr = new MemoryManager();
const result = await mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
config,
history: [{ role: 'user', parts: [{ text: 'hi' }] }],
});
expect(result.skippedReason).toBe('memory_pressure');
expect(result.cursor.processedOffset).toBeUndefined();
expect(runAutoMemoryExtract).not.toHaveBeenCalled();
});
/**
* B4: a queued (trailing) extract is also gated. Because the gate lives
* in runExtract the choke point both the direct and queued paths funnel
* through a trailing extract started after pressure spikes is skipped
* rather than bypassing the gate via startQueuedExtract.
*/
it('gates queued trailing extracts under memory pressure', async () => {
vi.mocked(runAutoMemoryExtract).mockClear();
let pressure: 'normal' | 'critical' = 'normal';
const config = makeMockConfig({
getMemoryPressureMonitor: vi.fn().mockReturnValue({
getPressureLevel: vi.fn(() => pressure),
}),
} as Partial<Config>);
let resolveFirst: (
value: Awaited<ReturnType<typeof runAutoMemoryExtract>>,
) => void;
const firstPromise = new Promise<
Awaited<ReturnType<typeof runAutoMemoryExtract>>
>((r) => {
resolveFirst = r;
});
vi.mocked(runAutoMemoryExtract).mockReturnValueOnce(firstPromise);
const mgr = new MemoryManager();
// First extract starts running (pressure normal).
void mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
config,
history: [{ role: 'user', parts: [{ text: 'first' }] }],
});
expect(runAutoMemoryExtract).toHaveBeenCalledTimes(1);
// Queue a trailing extract while the first is still running.
const queuedResult = await mgr.scheduleExtract({
projectRoot: '/project',
sessionId: 'sess',
config,
history: [{ role: 'user', parts: [{ text: 'trailing' }] }],
});
expect(queuedResult.skippedReason).toBe('queued');
// Pressure spikes, then the first extract finishes → trailing dequeues.
pressure = 'critical';
resolveFirst!({
touchedTopics: [],
cursor: {
sessionId: 'sess',
processedOffset: 1,
updatedAt: new Date().toISOString(),
},
});
// The trailing extract must NOT call the real runAutoMemoryExtract a
// second time — the gate in runExtract skips it under pressure.
await mgr.drain({ timeoutMs: 500 });
expect(runAutoMemoryExtract).toHaveBeenCalledTimes(1);
});
/**
* B4b: skill review pressure gate lives in runSkillReview (mirroring
* the extract pattern), producing a skipped task record.
*/
it('skips skill review when monitor reports hard pressure', async () => {
vi.mocked(runSkillReviewByAgent).mockClear();
const config = makeMockConfig({
getMemoryPressureMonitor: vi.fn().mockReturnValue({
getPressureLevel: vi.fn().mockReturnValue('hard'),
}),
} as Partial<Config>);
const mgr = new MemoryManager();
const result = mgr.scheduleSkillReview({
projectRoot: '/project',
sessionId: 'sess',
history: [{ role: 'user', parts: [{ text: 'hi' }] }],
toolCallCount: 25,
threshold: 2,
skillsModified: false,
config,
});
expect(result.status).toBe('scheduled');
const record = await result.promise!;
expect(record.status).toBe('skipped');
expect(record.metadata?.['skippedReason']).toBe('memory_pressure');
expect(runSkillReviewByAgent).not.toHaveBeenCalled();
});
/**
* B4c: after the gate fires, the finally block must clean up the
* skillReviewInFlightByProject Map entry. A second call to
* scheduleSkillReview must NOT return already_running.
*/
it('cleans up Map entry after pressure gate fires', async () => {
const config = makeMockConfig({
getMemoryPressureMonitor: vi.fn().mockReturnValue({
getPressureLevel: vi.fn().mockReturnValue('hard'),
}),
} as Partial<Config>);
const mgr = new MemoryManager();
// First call: gate fires, skipped record pushed to promise.
const first = mgr.scheduleSkillReview({
projectRoot: '/project',
sessionId: 'sess',
history: [{ role: 'user', parts: [{ text: 'hi' }] }],
toolCallCount: 25,
threshold: 2,
skillsModified: false,
config,
});
expect(first.status).toBe('scheduled');
await first.promise!;
vi.mocked(runSkillReviewByAgent).mockClear();
// Second call: must not return already_running — the Map entry was
// cleaned up by the finally block.
const second = mgr.scheduleSkillReview({
projectRoot: '/project',
sessionId: 'sess',
history: [{ role: 'user', parts: [{ text: 'hi' }] }],
toolCallCount: 25,
threshold: 2,
skillsModified: false,
config,
});
expect(second.status).toBe('scheduled');
expect(second.skippedReason).toBeUndefined();
});
/**
* B5: scheduleDream also gates on memory pressure. The dream path does
* its own structuredClone of full history, so hard/critical pressure
* should skip it alongside extract.
*/
it('skips dream with memory_pressure when monitor reports critical', async () => {
const config = makeMockConfig({
getMemoryPressureMonitor: vi.fn().mockReturnValue({
getPressureLevel: vi.fn().mockReturnValue('critical'),
}),
getManagedAutoDreamEnabled: vi.fn().mockReturnValue(true),
} as Partial<Config>);
const mgr = new MemoryManager();
const result = await mgr.scheduleDream({
projectRoot: '/project',
sessionId: 'sess',
config,
});
expect(result.status).toBe('skipped');
expect(result.skippedReason).toBe('memory_pressure');
});
});
});

View file

@ -147,7 +147,8 @@ export interface SkillReviewScheduleResult {
| 'below_threshold'
| 'skills_modified_in_session'
| 'disabled'
| 'already_running';
| 'already_running'
| 'memory_pressure';
promise?: Promise<MemoryTaskRecord>;
}
@ -175,7 +176,8 @@ export interface DreamScheduleResult {
| 'min_sessions'
| 'scan_throttled'
| 'locked'
| 'running';
| 'running'
| 'memory_pressure';
promise?: Promise<MemoryTaskRecord>;
}
@ -674,11 +676,26 @@ export class MemoryManager {
return this.track(record.id, this.runExtract(record.id, params)) as never;
}
/**
* True when the runtime is under hard or critical memory pressure, as
* reported by the shared MemoryPressureMonitor (#5147). The monitor is
* cgroup-aware and compares RSS/heap against their actual limits as a
* ratio, so this adapts to `--max-old-space-size`, containers, and large
* hosts alike unlike an absolute megabyte threshold. Returns false when
* no monitor is wired (e.g. unit tests, headless), so extraction proceeds
* normally in those contexts.
*/
private isUnderMemoryPressure(config?: Config): boolean {
const level = config?.getMemoryPressureMonitor?.()?.getPressureLevel?.();
return level === 'hard' || level === 'critical';
}
private async runExtract(
taskId: string,
params: ScheduleExtractParams,
): Promise<Awaited<ReturnType<typeof runAutoMemoryExtract>>> {
const record = this.tasks.get(taskId)!;
this.extractCurrentTaskId.set(params.projectRoot, taskId);
this.extractRunning.add(params.projectRoot);
this.update(record, {
@ -689,6 +706,39 @@ export class MemoryManager {
const t0 = Date.now();
try {
// Memory-pressure gate. Checked inside try so the finally block
// always runs — extractRunning/extractCurrentTaskId are cleaned up
// and startQueuedExtract is called regardless of the gate outcome.
if (this.isUnderMemoryPressure(params.config)) {
debugLogger.warn('Skipping extract: memory pressure too high.');
this.update(record, {
status: 'skipped',
progressText: 'Skipped: memory pressure too high for extraction.',
metadata: { skippedReason: 'memory_pressure' },
});
if (params.config) {
logMemoryExtract(
params.config,
new MemoryExtractEvent({
trigger: 'auto',
status: 'skipped',
skipped_reason: 'memory_pressure',
patches_count: 0,
touched_topics: [],
duration_ms: 0,
}),
);
}
return {
touchedTopics: [],
skippedReason: 'memory_pressure' as const,
cursor: {
sessionId: params.sessionId,
updatedAt: (params.now ?? new Date()).toISOString(),
},
};
}
const result = await runAutoMemoryExtract(params);
const durationMs = Date.now() - t0;
this.update(record, {
@ -811,7 +861,20 @@ export class MemoryManager {
params: ScheduleSkillReviewParams,
): Promise<MemoryTaskRecord> {
this.skillReviewInFlightByProject.set(params.projectRoot, record.id);
try {
// Memory-pressure gate — inside try so finally always cleans up
// the skillReviewInFlightByProject entry.
if (this.isUnderMemoryPressure(params.config)) {
this.update(record, {
status: 'skipped',
progressText: 'Skipped: memory pressure too high.',
metadata: { skippedReason: 'memory_pressure' },
});
debugLogger.warn('Skipping skill review: memory pressure too high.');
return record;
}
const result = await runSkillReviewByAgent({
config: params.config!,
projectRoot: params.projectRoot,
@ -857,6 +920,14 @@ export class MemoryManager {
return { status: 'skipped', skippedReason: 'disabled' };
}
// Also skip dream under memory pressure — dream does its own
// structuredClone of full history, and shouldn't add extra pressure
// when the heap is already under hard/critical load.
if (this.isUnderMemoryPressure(params.config)) {
debugLogger.warn('Skipping dream: memory pressure too high.');
return { status: 'skipped', skippedReason: 'memory_pressure' };
}
const now = params.now ?? new Date();
const minHours =
params.minHoursBetweenDreams ?? DEFAULT_AUTO_DREAM_MIN_HOURS;

View file

@ -1288,7 +1288,11 @@ export class MemoryExtractEvent implements BaseTelemetryEvent {
/** 'auto' = triggered by session turn; 'manual' = user-initiated */
trigger: 'auto' | 'manual';
status: 'completed' | 'skipped' | 'failed';
skipped_reason?: 'already_running' | 'queued' | 'memory_tool';
skipped_reason?:
| 'already_running'
| 'queued'
| 'memory_tool'
| 'memory_pressure';
patches_count: number;
touched_topics: string;
duration_ms: number;
@ -1296,7 +1300,11 @@ export class MemoryExtractEvent implements BaseTelemetryEvent {
constructor(params: {
trigger: 'auto' | 'manual';
status: 'completed' | 'skipped' | 'failed';
skipped_reason?: 'already_running' | 'queued' | 'memory_tool';
skipped_reason?:
| 'already_running'
| 'queued'
| 'memory_tool'
| 'memory_pressure';
patches_count: number;
touched_topics: string[];
duration_ms: number;