mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:30:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user