From 7e9824e3948aa1099508cf8a6646ab9abd0a0ac5 Mon Sep 17 00:00:00 2001 From: chaithanyak42 Date: Thu, 2 Apr 2026 13:14:41 +0530 Subject: [PATCH] fix(openclaw): dream verification, failed-turn cleanup, atomic lock 1. Write-only verification: only count memory_store, memory_update, memory_forget, memory_delete_all as proof of dream execution. Removed memory_list (read-only, orient-only pass should not count). Scan only the last assistant message (this turn), not the full session snapshot, to avoid matching earlier turns' tool calls. 2. Failed-turn cleanup: agent_end now handles dreamSessionId cleanup BEFORE the !event.success early return. A failed/aborted turn after lock acquisition releases the lock immediately instead of waiting for the 1-hour stale timeout. 3. Atomic lock: replaced read-then-write with exclusive create flag (wx). Two racing processes: only one succeeds, other gets EEXIST. Stale locks are unlinked before the exclusive create attempt. Co-Authored-By: Claude Opus 4.6 (1M context) --- openclaw/dream-gate.ts | 17 +++++++++----- openclaw/index.ts | 50 ++++++++++++++++++++++++++---------------- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/openclaw/dream-gate.ts b/openclaw/dream-gate.ts index 028382a88..46435a2b5 100644 --- a/openclaw/dream-gate.ts +++ b/openclaw/dream-gate.ts @@ -150,18 +150,23 @@ export function acquireDreamLock(stateDir: string): boolean { const lock = JSON.parse(raw) as DreamLock; const age = Date.now() - lock.startedAt; if (age < LOCK_STALE_MS) { - // Lock is held and not stale - return false; + return false; // Held and not stale } - // Stale lock, reclaim + // Stale lock — remove it before attempting exclusive create + try { fs.unlinkSync(lp); } catch { /* race ok */ } } catch { // No lock file, proceed } - // Write lock + // Atomic create with exclusive flag (wx). If two processes race, + // only one succeeds. The other gets EEXIST. const lock: DreamLock = { pid: process.pid, startedAt: Date.now() }; - fs.writeFileSync(lp, JSON.stringify(lock)); - return true; + try { + fs.writeFileSync(lp, JSON.stringify(lock), { flag: "wx" }); + return true; + } catch { + return false; // Lost race + } } /** diff --git a/openclaw/index.ts b/openclaw/index.ts index 2485e29e4..2900b92b0 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -1348,40 +1348,52 @@ function registerHooks( let dreamSessionId: string | undefined; api.on("agent_end", async (event: any, ctx: any) => { - if (!event.success) return; const sessionId = ctx?.sessionKey ?? undefined; const trigger = ctx?.trigger ?? undefined; if (sessionId) session.setCurrentSessionId(sessionId); - // If dream was triggered for THIS session, check if it actually ran + // If dream was triggered for THIS session, handle cleanup regardless + // of success/failure. A failed turn must still release the lock. const stateDir = session.getStateDir(); if (dreamSessionId && dreamSessionId === sessionId && stateDir) { - // Verify the model actually called memory tools (not false completion). - // Check event.messages for memory_store, memory_update, memory_forget tool calls. - const messages = event.messages ?? []; - const dreamToolsUsed = messages.some((m: any) => { - if (m.role !== "assistant") return false; - const content = Array.isArray(m.content) ? m.content : []; - return content.some((block: any) => - block.type === "tool_use" && - ["memory_store", "memory_update", "memory_forget", "memory_delete_all", "memory_list"].includes(block.name) - ); - }); + dreamSessionId = undefined; - if (dreamToolsUsed) { + if (!event.success) { + // Turn failed/aborted after lock acquired. Release lock, do not + // record completion. Gates will re-trigger next eligible turn. + releaseDreamLock(stateDir); + api.logger.warn("openclaw-mem0: auto-dream turn failed, lock released, will retry"); + return; + } + + // Verify the model actually performed WRITE operations (not just reads). + // Only count memory_store, memory_update, memory_forget, memory_delete_all. + // Exclude memory_list and memory_search (read-only, orient-only pass). + // Scan only the LAST assistant message (this turn), not the full session + // snapshot, to avoid matching earlier tool calls from prior turns. + const WRITE_TOOLS = new Set(["memory_store", "memory_update", "memory_forget", "memory_delete_all"]); + const messages = event.messages ?? []; + // Find the last assistant message (this turn's output) + const lastAssistant = [...messages].reverse().find((m: any) => m.role === "assistant"); + const writeToolUsed = lastAssistant && Array.isArray(lastAssistant.content) + ? lastAssistant.content.some((block: any) => + block.type === "tool_use" && WRITE_TOOLS.has(block.name) + ) + : false; + + if (writeToolUsed) { releaseDreamLock(stateDir); recordDreamCompletion(stateDir); - api.logger.info("openclaw-mem0: auto-dream completed (verified tool usage), lock released"); + api.logger.info("openclaw-mem0: auto-dream completed (verified write tool usage), lock released"); } else { - // Model did not run dream tools. Release lock but do NOT record completion. - // Gates will re-trigger on the next eligible turn. releaseDreamLock(stateDir); - api.logger.warn("openclaw-mem0: auto-dream injected but model did not execute consolidation. Lock released, will retry."); + api.logger.warn("openclaw-mem0: auto-dream injected but no write tools executed. Lock released, will retry."); } - dreamSessionId = undefined; return; } + if (!event.success) return; + // Track session for dream gating (interactive turns only) if (stateDir && sessionId && !isNonInteractiveTrigger(trigger, sessionId)) { incrementSessionCount(stateDir, sessionId);