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.
This commit is contained in:
JC
2026-05-02 05:30:10 -07:00
committed by GitHub
parent 237d0869dc
commit 228e5a238c
3 changed files with 170 additions and 14 deletions

View File

@@ -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.

View File

@@ -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, {

View File

@@ -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<string>();
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;