mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
e911f605d1
commit
abfdcc112b
9 changed files with 987 additions and 92 deletions
|
|
@ -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 过滤规则**:
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue