From 8150c363b5800a3bc0cf03baee8fb08b53693015 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 03:11:31 +0100 Subject: [PATCH] fix: stabilize memory dreaming QA --- .../memory-core/src/dreaming-phases.test.ts | 69 +++++++++++++++++++ extensions/memory-core/src/dreaming-phases.ts | 19 +++-- qa/scenarios/memory/memory-dreaming-sweep.md | 4 +- .../server-startup-post-attach.test.ts | 66 +++++++++++++++++- src/gateway/server-startup-post-attach.ts | 9 ++- 5 files changed, 155 insertions(+), 12 deletions(-) diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index 0d9e6f75528..498238aded6 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { RequestScopedSubagentRuntimeError } from "openclaw/plugin-sdk/error-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core"; import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core"; import { @@ -253,6 +254,74 @@ describe("memory-core dreaming phases", () => { expect(subagent.deleteSession).toHaveBeenNthCalledWith(2, { sessionKey: expectedSessionKey }); }); + it("swallows synchronous request-scoped cleanup failures after narrative fallback", async () => { + const workspaceDir = await createDreamingWorkspace(); + await writeDailyNote(workspaceDir, [ + `# ${DREAMING_TEST_DAY}`, + "", + "- Move backups to S3 Glacier.", + "- Keep retention at 365 days.", + ]); + const testConfig: OpenClawConfig = { + ...LIGHT_DREAMING_TEST_CONFIG, + agents: { + defaults: { + workspace: workspaceDir, + userTimezone: "UTC", + }, + }, + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + timezone: "UTC", + phases: { + light: { + enabled: true, + limit: 20, + lookbackDays: 2, + }, + rem: { + enabled: false, + limit: 0, + lookbackDays: 2, + }, + }, + }, + }, + }, + }, + }, + }; + const subagent = createMockNarrativeSubagent(); + subagent.run.mockRejectedValue(new RequestScopedSubagentRuntimeError()); + subagent.deleteSession.mockImplementation(() => { + throw new RequestScopedSubagentRuntimeError(); + }); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + await expect( + runDreamingSweepPhases({ + workspaceDir, + cfg: testConfig, + pluginConfig: resolveMemoryCorePluginConfig(testConfig), + logger, + subagent, + nowMs: Date.parse("2026-04-05T10:05:00.000Z"), + }), + ).resolves.toBeUndefined(); + + const dreams = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8"); + expect(dreams).toContain("Move backups to S3 Glacier."); + expect(logger.error).not.toHaveBeenCalled(); + }); + it("does not re-ingest managed light dreaming blocks from daily notes", async () => { const workspaceDir = await createDreamingWorkspace(); await withDreamingTestClock(async () => { diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index bc0bbde1878..f7979cbe656 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -1643,6 +1643,17 @@ async function runRemDreaming(params: { } } +async function deleteNarrativeSessionBestEffort( + subagent: Parameters[0]["subagent"], + sessionKey: string, +): Promise { + try { + await subagent.deleteSession({ sessionKey }); + } catch { + // Cleanup is best-effort; request-scoped runtimes can throw synchronously. + } +} + export async function runDreamingSweepPhases(params: { workspaceDir: string; pluginConfig?: Record; @@ -1675,9 +1686,7 @@ export async function runDreamingSweepPhases(params: { phase: "light", nowMs: sweepNowMs, }); - await params.subagent.deleteSession({ sessionKey: lightSessionKey }).catch(() => { - // Swallow errors — this is best-effort cleanup. - }); + await deleteNarrativeSessionBestEffort(params.subagent, lightSessionKey); } } @@ -1701,9 +1710,7 @@ export async function runDreamingSweepPhases(params: { phase: "rem", nowMs: sweepNowMs, }); - await params.subagent.deleteSession({ sessionKey: remSessionKey }).catch(() => { - // Swallow errors — this is best-effort cleanup. - }); + await deleteNarrativeSessionBestEffort(params.subagent, remSessionKey); } } } diff --git a/qa/scenarios/memory/memory-dreaming-sweep.md b/qa/scenarios/memory/memory-dreaming-sweep.md index 38ff22a8408..a54d405ace7 100644 --- a/qa/scenarios/memory/memory-dreaming-sweep.md +++ b/qa/scenarios/memory/memory-dreaming-sweep.md @@ -249,7 +249,7 @@ steps: afterTs: ref: cronRunStartedAt timeoutMs: - expr: liveTurnTimeoutMs(env, 90000) + expr: liveTurnTimeoutMs(env, 180000) - assert: expr: "finishedRun.status === 'ok'" message: @@ -260,7 +260,7 @@ steps: - lambda: async: true expr: "(async () => { const status = await readDoctorMemoryStatus(env); const lightReport = await fs.readFile(lightReportPath, 'utf8').catch(() => ''); const remReport = await fs.readFile(remReportPath, 'utf8').catch(() => ''); const promotedMemory = await fs.readFile(memoryPath, 'utf8').catch(() => ''); if (!lightReport.includes('# Light Sleep')) return undefined; if (!remReport.includes('# REM Sleep')) return undefined; if (!promotedMemory.includes(config.expectedNeedle)) return undefined; if (status.dreaming?.phases?.deep?.managedCronPresent !== true) return undefined; if ((status.dreaming?.promotedTotal ?? 0) < 1) return undefined; return { status, lightReport, remReport, promotedMemory }; })()" - - expr: liveTurnTimeoutMs(env, 90000) + - expr: liveTurnTimeoutMs(env, 180000) - 1000 finally: - call: patchConfig diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 23c9cbf0f44..12406685dd7 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -5,6 +5,10 @@ const hoisted = vi.hoisted(() => { const startGmailWatcherWithLogs = vi.fn(async () => undefined); const loadInternalHooks = vi.fn(async () => 0); const setInternalHooksEnabled = vi.fn(); + const hasInternalHookListeners = vi.fn(() => false); + const startupHookEvent = { type: "gateway", action: "startup", sessionKey: "gateway:startup" }; + const createInternalHookEvent = vi.fn(() => startupHookEvent); + const triggerInternalHook = vi.fn(async () => undefined); const startGatewayMemoryBackend = vi.fn(async () => undefined); const scheduleGatewayUpdateCheck = vi.fn(() => () => {}); const startGatewayTailscaleExposure = vi.fn(async () => null); @@ -22,6 +26,10 @@ const hoisted = vi.hoisted(() => { startGmailWatcherWithLogs, loadInternalHooks, setInternalHooksEnabled, + hasInternalHookListeners, + startupHookEvent, + createInternalHookEvent, + triggerInternalHook, startGatewayMemoryBackend, scheduleGatewayUpdateCheck, startGatewayTailscaleExposure, @@ -61,9 +69,10 @@ vi.mock("../hooks/gmail-watcher-lifecycle.js", () => ({ })); vi.mock("../hooks/internal-hooks.js", () => ({ - createInternalHookEvent: vi.fn(() => ({})), + createInternalHookEvent: hoisted.createInternalHookEvent, + hasInternalHookListeners: hoisted.hasInternalHookListeners, setInternalHooksEnabled: hoisted.setInternalHooksEnabled, - triggerInternalHook: vi.fn(async () => undefined), + triggerInternalHook: hoisted.triggerInternalHook, })); vi.mock("../hooks/loader.js", () => ({ @@ -105,7 +114,8 @@ vi.mock("./server-tailscale.js", () => ({ startGatewayTailscaleExposure: hoisted.startGatewayTailscaleExposure, })); -const { startGatewayPostAttachRuntime } = await import("./server-startup-post-attach.js"); +const { startGatewayPostAttachRuntime, startGatewaySidecars } = + await import("./server-startup-post-attach.js"); const { STARTUP_UNAVAILABLE_GATEWAY_METHODS } = await import("./server-startup-unavailable-methods.js"); @@ -118,6 +128,10 @@ describe("startGatewayPostAttachRuntime", () => { hoisted.startGmailWatcherWithLogs.mockClear(); hoisted.loadInternalHooks.mockClear(); hoisted.setInternalHooksEnabled.mockClear(); + hoisted.hasInternalHookListeners.mockReset(); + hoisted.hasInternalHookListeners.mockReturnValue(false); + hoisted.createInternalHookEvent.mockClear(); + hoisted.triggerInternalHook.mockClear(); hoisted.startGatewayMemoryBackend.mockClear(); hoisted.scheduleGatewayUpdateCheck.mockClear(); hoisted.startGatewayTailscaleExposure.mockClear(); @@ -200,6 +214,52 @@ describe("startGatewayPostAttachRuntime", () => { expect([...unavailableGatewayMethods]).toEqual([]); expect(startGatewaySidecars).toHaveBeenCalledTimes(1); }); + + it("dispatches registered gateway startup internal hooks without configured hook packs", async () => { + vi.useFakeTimers(); + hoisted.hasInternalHookListeners.mockReturnValue(true); + const cfg = {} as never; + const deps = {} as never; + + try { + await startGatewaySidecars({ + cfg, + pluginRegistry: createPostAttachParams().pluginRegistry, + defaultWorkspaceDir: "/tmp/openclaw-workspace", + deps, + startChannels: vi.fn(async () => undefined), + log: { warn: vi.fn() }, + logHooks: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + logChannels: { + info: vi.fn(), + error: vi.fn(), + }, + }); + + expect(hoisted.loadInternalHooks).not.toHaveBeenCalled(); + expect(hoisted.hasInternalHookListeners).toHaveBeenCalledWith("gateway", "startup"); + + await vi.advanceTimersByTimeAsync(250); + + expect(hoisted.createInternalHookEvent).toHaveBeenCalledWith( + "gateway", + "startup", + "gateway:startup", + { + cfg, + deps, + workspaceDir: "/tmp/openclaw-workspace", + }, + ); + expect(hoisted.triggerInternalHook).toHaveBeenCalledWith(hoisted.startupHookEvent); + } finally { + vi.useRealTimers(); + } + }); }); function createPostAttachRuntimeDeps( diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index b06c782fb12..74efd7a4367 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -40,6 +40,11 @@ function shouldStartGatewayMemoryBackend(cfg: OpenClawConfig): boolean { return cfg.memory?.backend === "qmd"; } +async function hasGatewayStartupInternalHookListeners(): Promise { + const { hasInternalHookListeners } = await import("../hooks/internal-hooks.js"); + return hasInternalHookListeners("gateway", "startup"); +} + async function prewarmConfiguredPrimaryModel(params: { cfg: OpenClawConfig; log: { warn: (msg: string) => void }; @@ -232,7 +237,9 @@ export async function startGatewaySidecars(params: { } }); - if (internalHooksConfigured) { + const shouldDispatchGatewayStartupInternalHook = + internalHooksConfigured || (await hasGatewayStartupInternalHookListeners()); + if (shouldDispatchGatewayStartupInternalHook) { setTimeout(() => { void import("../hooks/internal-hooks.js").then( ({ createInternalHookEvent, triggerInternalHook }) => {