From 831b94fee936c11cdbecace76253a2f59f951b42 Mon Sep 17 00:00:00 2001 From: iacore Date: Wed, 8 Jul 2026 20:49:12 +0800 Subject: [PATCH] fix(mnemopi): skip sleep on dispose so /quit avoids cross-session bank consolidation --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/mnemopi/state.ts | 30 ++++++++++------ .../coding-agent/test/memory-tools.test.ts | 34 +++++++++++++------ 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f9c3c149b..65676ad01 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -128,7 +128,7 @@ - Cached failed `!command` config resolutions and timed out extension dynamic model fetches after 15 seconds. ### Fixed -- Fixed `/quit` and `/exit` hanging for seconds during interactive shutdown by making the mnemopi dispose path run a lighter, bounded consolidation: it retains the current session without scheduling LLM fact extraction and sleeps only the current session, while the `/memory enqueue` path and end-of-session backend enqueue still perform full cross-session consolidation. ([#3641](https://github.com/can1357/oh-my-pi/issues/3641)) +- Fixed `/quit` and `/exit` hanging during interactive shutdown by making the mnemopi dispose path retain the current session and flush in-flight extractions without sleeping the bank; the `/memory enqueue` path and end-of-session backend enqueue still perform full cross-session consolidation. ([#3641](https://github.com/can1357/oh-my-pi/issues/3641)) ## [16.3.6] - 2026-07-04 diff --git a/packages/coding-agent/src/mnemopi/state.ts b/packages/coding-agent/src/mnemopi/state.ts index abbcaaa91..c3f277820 100644 --- a/packages/coding-agent/src/mnemopi/state.ts +++ b/packages/coding-agent/src/mnemopi/state.ts @@ -526,14 +526,18 @@ export class MnemopiSessionState { * (the full cross-session consolidation used by `/memory enqueue`). When * false (the default), run only `sleep` on the current session for a * lighter, bounded shutdown pass. + * @param options.sleep - When false, skips the bank sleep step entirely. + * Used on the interactive shutdown path so `dispose` does not block on + * synchronous consolidation of old working rows from previous sessions. * @param options.extract - When false, the retained transcript is stored but * no LLM fact extraction is scheduled. Used on the interactive shutdown path * so `dispose` does not block on a fresh LLM round-trip. */ - async consolidate(options: { full?: boolean; extract?: boolean } = {}): Promise { + async consolidate(options: { full?: boolean; extract?: boolean; sleep?: boolean } = {}): Promise { await this.forceRetainCurrentSession({ extract: options.extract }); for (const memory of this.scoped.owned) { await memory.flushExtractions(); + if (options.sleep === false) continue; if (options.full) { memory.sleepAllSessions(false); } else { @@ -543,12 +547,16 @@ export class MnemopiSessionState { } /** - * Release the per-session resources. Defaults to running {@link consolidate} - * before closing handles so normal session shutdown promotes working memory - * into long-term storage. Callers that are about to delete the DB files — - * e.g. `mnemopiBackend.clear` — pass `{ consolidate: false }` to skip the - * extraction/sleep pass, since spending tokens on memories that will be - * wiped on the next line is wasted work (PR #2327 review). + * Release the per-session resources. Defaults to running a lighter + * {@link consolidate} pass before closing handles: it retains the current + * transcript and flushes in-flight extractions, but skips the synchronous + * bank sleep so normal session shutdown returns promptly. Full promotion of + * working memory into long-term storage is still performed by the explicit + * `/memory enqueue` and backend enqueue paths. Callers that are about to + * delete the DB files — e.g. `mnemopiBackend.clear` — pass + * `{ consolidate: false }` to skip the retain/flush pass, since spending + * tokens on memories that will be wiped on the next line is wasted work + * (PR #2327 review). * * `timeoutMs` caps how long the consolidate await blocks the caller * (the user-visible `/quit` / `/exit` shutdown path passes this so @@ -572,9 +580,11 @@ export class MnemopiSessionState { closeOwned(); return; } - const consolidatePromise = this.consolidate({ full: false, extract: false }).catch((error: unknown) => { - logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) }); - }); + const consolidatePromise = this.consolidate({ full: false, extract: false, sleep: false }).catch( + (error: unknown) => { + logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) }); + }, + ); const { timeoutMs } = options; if (timeoutMs !== undefined && timeoutMs > 0) { const TIMED_OUT = Symbol("mnemopi.dispose.timedOut"); diff --git a/packages/coding-agent/test/memory-tools.test.ts b/packages/coding-agent/test/memory-tools.test.ts index e4abf07bf..a6911182d 100644 --- a/packages/coding-agent/test/memory-tools.test.ts +++ b/packages/coding-agent/test/memory-tools.test.ts @@ -530,7 +530,7 @@ describe("Mnemopi backend lifecycle", () => { await childState?.dispose(); }); - it("flushes extractions, sleeps, and closes every owned bank on session shutdown (#2320)", async () => { + it("flushes extractions and closes every owned bank on session shutdown (#2320)", async () => { const config = makeMnemopiConfig({ scoping: "per-project-tagged", bank: "project-alpha", @@ -563,14 +563,11 @@ describe("Mnemopi backend lifecycle", () => { expect(retainSpy).toHaveBeenCalledTimes(1); for (const bank of perBank) { expect(bank.flush).toHaveBeenCalledTimes(1); - expect(bank.sleep).toHaveBeenCalledTimes(1); - expect(bank.sleep).toHaveBeenCalledWith(false); + expect(bank.sleep).not.toHaveBeenCalled(); expect(bank.close).toHaveBeenCalledTimes(1); const flushedAt = bank.flush.mock.invocationCallOrder[0]; - const sleptAt = bank.sleep.mock.invocationCallOrder[0]; const closedAt = bank.close.mock.invocationCallOrder[0]; - expect(flushedAt).toBeLessThan(sleptAt); - expect(sleptAt).toBeLessThan(closedAt); + expect(flushedAt).toBeLessThan(closedAt); expect(retainSpy.mock.invocationCallOrder[0]).toBeLessThan(closedAt); } // State already consumed its owned resources; the afterEach hook would @@ -614,7 +611,7 @@ describe("Mnemopi backend lifecycle", () => { registeredMnemopiState = undefined; }); - it("dispose with no timeoutMs awaits consolidate to completion (#3641 — preserves #2320 contract)", async () => { + it("dispose with no timeoutMs retains, flushes, and closes without sleeping (#3641)", async () => { const state = registerMnemopiState(); const retainMemory = state.getScopedRetainTarget().memory; const flushSpy = vi.spyOn(retainMemory, "flushExtractions").mockResolvedValue(); @@ -624,11 +621,10 @@ describe("Mnemopi backend lifecycle", () => { await state.dispose(); // Unbounded dispose still runs the consolidate-then-close pipeline, but - // uses the lighter current-session sleep rather than the full all-sessions - // scan so the interactive shutdown path stays fast (#3641). + // skips the synchronous bank sleep so the interactive shutdown path stays + // fast (#3641). Full consolidation remains reachable via `/memory enqueue`. expect(flushSpy).toHaveBeenCalledTimes(1); - expect(sleepSpy).toHaveBeenCalledTimes(1); - expect(sleepSpy).toHaveBeenCalledWith(false); + expect(sleepSpy).not.toHaveBeenCalled(); expect(closeSpy).toHaveBeenCalledTimes(1); registeredMnemopiState = undefined; @@ -646,6 +642,22 @@ describe("Mnemopi backend lifecycle", () => { registeredMnemopiState = undefined; }); + it("consolidate({ sleep: false }) retains and flushes without sleeping the bank", async () => { + const state = registerMnemopiState(); + const retainMemory = state.getScopedRetainTarget().memory; + vi.spyOn(state, "forceRetainCurrentSession").mockResolvedValue(); + vi.spyOn(retainMemory, "flushExtractions").mockResolvedValue(); + const sleepAllSessionsSpy = vi.spyOn(retainMemory, "sleepAllSessions"); + const sleepSpy = vi.spyOn(retainMemory, "sleep"); + + await state.consolidate({ sleep: false }); + + expect(sleepAllSessionsSpy).not.toHaveBeenCalled(); + expect(sleepSpy).not.toHaveBeenCalled(); + + registeredMnemopiState = undefined; + }); + it("consolidate({ full: true }) runs the full cross-session sleepAllSessions", async () => { const state = registerMnemopiState(); const retainMemory = state.getScopedRetainTarget().memory;