From 228e5a238c1dc976dff5caa5332e38fa69b54de1 Mon Sep 17 00:00:00 2001 From: JC Date: Sat, 2 May 2026 05:30:10 -0700 Subject: [PATCH] fix: recover topic-suffixed restart locks (#76052) Fix restart recovery for main sessions that use topic-suffixed transcript files by matching cleaned transcript lock paths directly while preserving the canonical session-id fallback for stale metadata. Thanks @anyech. --- CHANGELOG.md | 1 + .../main-session-restart-recovery.test.ts | 125 +++++++++++++++++- src/agents/main-session-restart-recovery.ts | 58 ++++++-- 3 files changed, 170 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf16d159b9..363706888d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/restart recovery: match cleaned transcript locks by exact transcript lock paths plus the canonical session fallback, so interrupted main sessions using topic-suffixed transcripts resume after gateway restart. Refs #76052. Thanks @anyech. - Telegram/native commands: pass persisted session files into plugin commands for topic-bound sessions, so `/codex bind` works from Telegram forum topics. Refs #75845 and #76049. Thanks @MatthewSchleder. - Security audit/plugins: ignore plugin install backup, disabled, and dependency debris directories when enumerating installed plugin roots, avoiding false-positive findings for `.openclaw-install-backups` after plugin updates. Fixes #75456. - Telegram: honor runtime conversation bindings for native slash commands in bound top-level groups, so commands like `/status@bot` route to the active non-`main` session instead of falling back to the default route. Fixes #75405; supersedes #75558. Thanks @ziptbm and @yfge. diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 2de96b27574..0bf84c978ba 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -44,9 +44,9 @@ async function writeTranscript( await fs.writeFile(path.join(sessionsDir, `${sessionId}.jsonl`), `${lines}\n`); } -function cleanedLock(sessionsDir: string, sessionId: string): SessionLockInspection { +function cleanedLockForPath(lockPath: string): SessionLockInspection { return { - lockPath: path.join(sessionsDir, `${sessionId}.jsonl.lock`), + lockPath, pid: 999_999, pidAlive: false, createdAt: new Date(Date.now() - 1_000).toISOString(), @@ -57,6 +57,10 @@ function cleanedLock(sessionsDir: string, sessionId: string): SessionLockInspect }; } +function cleanedLock(sessionsDir: string, sessionId: string): SessionLockInspection { + return cleanedLockForPath(path.join(sessionsDir, `${sessionId}.jsonl.lock`)); +} + describe("main-session-restart-recovery", () => { it("marks only main running sessions whose transcript lock was cleaned", async () => { const sessionsDir = await makeSessionsDir(); @@ -94,6 +98,123 @@ describe("main-session-restart-recovery", () => { expect(store["agent:main:other"]?.abortedLastRun).toBeUndefined(); }); + it("marks a running main session whose cleaned transcript lock is topic-suffixed", async () => { + const sessionsDir = await makeSessionsDir(); + const sessionId = "main-session"; + const sessionFile = `${sessionId}-topic-1234567890.jsonl`; + await writeStore(sessionsDir, { + "agent:main:discord:channel:123:thread:1234567890": { + sessionId, + sessionFile, + updatedAt: Date.now() - 10_000, + status: "running", + }, + }); + + const result = await markRestartAbortedMainSessionsFromLocks({ + sessionsDir, + cleanedLocks: [cleanedLockForPath(path.join(sessionsDir, `${sessionFile}.lock`))], + }); + + const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); + expect(result).toEqual({ marked: 1, skipped: 0 }); + expect(store["agent:main:discord:channel:123:thread:1234567890"]?.abortedLastRun).toBe(true); + }); + + it("does not mark a session for an unrelated topic lock that only shares its id prefix", async () => { + const sessionsDir = await makeSessionsDir(); + await writeStore(sessionsDir, { + "agent:main:main": { + sessionId: "main-session", + sessionFile: "main-session.jsonl", + updatedAt: Date.now() - 10_000, + status: "running", + }, + }); + + const result = await markRestartAbortedMainSessionsFromLocks({ + sessionsDir, + cleanedLocks: [ + cleanedLockForPath(path.join(sessionsDir, "main-session-topic-unrelated.jsonl.lock")), + ], + }); + + const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); + expect(result).toEqual({ marked: 0, skipped: 0 }); + expect(store["agent:main:main"]?.abortedLastRun).toBeUndefined(); + }); + + it("normalizes relative cleaned lock paths against the current working directory", async () => { + const sessionsDir = await makeSessionsDir(); + const sessionId = "main-session"; + const sessionFile = `${sessionId}-topic-1234567890.jsonl`; + await writeStore(sessionsDir, { + "agent:main:discord:channel:123:thread:1234567890": { + sessionId, + sessionFile, + updatedAt: Date.now() - 10_000, + status: "running", + }, + }); + + const result = await markRestartAbortedMainSessionsFromLocks({ + sessionsDir, + cleanedLocks: [ + cleanedLockForPath( + path.relative(process.cwd(), path.join(sessionsDir, `${sessionFile}.lock`)), + ), + ], + }); + + const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); + expect(result).toEqual({ marked: 1, skipped: 0 }); + expect(store["agent:main:discord:channel:123:thread:1234567890"]?.abortedLastRun).toBe(true); + }); + + it("falls back to the session id transcript lock when persisted sessionFile is outside the sessions dir", async () => { + const sessionsDir = await makeSessionsDir(); + await writeStore(sessionsDir, { + "agent:main:main": { + sessionId: "main-session", + sessionFile: "../stale/outside.jsonl", + updatedAt: Date.now() - 10_000, + status: "running", + }, + }); + + const result = await markRestartAbortedMainSessionsFromLocks({ + sessionsDir, + cleanedLocks: [cleanedLock(sessionsDir, "main-session")], + }); + + const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); + expect(result).toEqual({ marked: 1, skipped: 0 }); + expect(store["agent:main:main"]?.abortedLastRun).toBe(true); + }); + + it("falls back to the session id transcript lock when persisted sessionFile belongs to another generated session", async () => { + const sessionsDir = await makeSessionsDir(); + const sessionId = "11111111-1111-4111-8111-111111111111"; + const otherSessionId = "22222222-2222-4222-8222-222222222222"; + await writeStore(sessionsDir, { + "agent:main:main": { + sessionId, + sessionFile: `${otherSessionId}.jsonl`, + updatedAt: Date.now() - 10_000, + status: "running", + }, + }); + + const result = await markRestartAbortedMainSessionsFromLocks({ + sessionsDir, + cleanedLocks: [cleanedLock(sessionsDir, sessionId)], + }); + + const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); + expect(result).toEqual({ marked: 1, skipped: 0 }); + expect(store["agent:main:main"]?.abortedLastRun).toBe(true); + }); + it("resumes marked sessions with a tool-result transcript tail", async () => { const sessionsDir = await makeSessionsDir(); await writeStore(sessionsDir, { diff --git a/src/agents/main-session-restart-recovery.ts b/src/agents/main-session-restart-recovery.ts index fdc6a8db64d..585a931a286 100644 --- a/src/agents/main-session-restart-recovery.ts +++ b/src/agents/main-session-restart-recovery.ts @@ -3,9 +3,16 @@ */ import crypto from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; -import { type SessionEntry, loadSessionStore, updateSessionStore } from "../config/sessions.js"; +import { + type SessionEntry, + loadSessionStore, + resolveSessionFilePath, + resolveSessionTranscriptPathInDir, + updateSessionStore, +} from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { readSessionMessagesAsync } from "../gateway/session-utils.fs.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -32,13 +39,38 @@ function shouldSkipMainRecovery(entry: SessionEntry, sessionKey: string): boolea ); } -function sessionIdFromLockPath(lockPath: string): string | undefined { - const fileName = path.basename(lockPath); - if (!fileName.endsWith(".jsonl.lock")) { +function normalizeTranscriptLockPath(lockPath: string): string | undefined { + const trimmed = lockPath.trim(); + if (!path.basename(trimmed).endsWith(".jsonl.lock")) { return undefined; } - const sessionId = fileName.slice(0, -".jsonl.lock".length).trim(); - return sessionId || undefined; + const resolved = path.resolve(trimmed); + try { + return path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved)); + } catch { + return resolved; + } +} + +function resolveEntryTranscriptLockPaths(params: { + entry: SessionEntry; + sessionsDir: string; +}): string[] { + const paths = new Set(); + const push = (resolvePath: () => string) => { + try { + paths.add(path.resolve(`${resolvePath()}.lock`)); + } catch { + // Keep restart recovery best-effort when session metadata is stale. + } + }; + push(() => + resolveSessionFilePath(params.entry.sessionId, params.entry, { + sessionsDir: params.sessionsDir, + }), + ); + push(() => resolveSessionTranscriptPathInDir(params.entry.sessionId, params.sessionsDir)); + return [...paths]; } function getMessageRole(message: unknown): string | undefined { @@ -157,16 +189,17 @@ export async function markRestartAbortedMainSessionsFromLocks(params: { cleanedLocks: SessionLockInspection[]; }): Promise<{ marked: number; skipped: number }> { const result = { marked: 0, skipped: 0 }; - const interruptedSessionIds = new Set( + const sessionsDir = path.resolve(params.sessionsDir); + const interruptedLockPaths = new Set( params.cleanedLocks - .map((lock) => sessionIdFromLockPath(lock.lockPath)) - .filter((sessionId): sessionId is string => Boolean(sessionId)), + .map((lock) => normalizeTranscriptLockPath(lock.lockPath)) + .filter((lockPath): lockPath is string => Boolean(lockPath)), ); - if (interruptedSessionIds.size === 0) { + if (interruptedLockPaths.size === 0) { return result; } - const storePath = path.join(path.resolve(params.sessionsDir), "sessions.json"); + const storePath = path.join(sessionsDir, "sessions.json"); await updateSessionStore( storePath, (store) => { @@ -178,7 +211,8 @@ export async function markRestartAbortedMainSessionsFromLocks(params: { result.skipped++; continue; } - if (!interruptedSessionIds.has(entry.sessionId)) { + const entryLockPaths = resolveEntryTranscriptLockPaths({ entry, sessionsDir }); + if (!entryLockPaths.some((lockPath) => interruptedLockPaths.has(lockPath))) { continue; } entry.abortedLastRun = true;