





























@@ -0,0 +1,169 @@
1+import fs from "node:fs/promises";
2+import os from "node:os";
3+import path from "node:path";
4+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+import { loadSessionStore, type SessionEntry } from "../config/sessions.js";
6+import { callGateway } from "../gateway/call.js";
7+import {
8+markRestartAbortedMainSessionsFromLocks,
9+recoverRestartAbortedMainSessions,
10+} from "./main-session-restart-recovery.js";
11+import type { SessionLockInspection } from "./session-write-lock.js";
12+13+vi.mock("../gateway/call.js", () => ({
14+callGateway: vi.fn(async () => ({ runId: "run-resumed" })),
15+}));
16+17+let tmpDir: string;
18+19+beforeEach(async () => {
20+vi.clearAllMocks();
21+tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-main-restart-recovery-"));
22+});
23+24+afterEach(async () => {
25+await fs.rm(tmpDir, { recursive: true, force: true });
26+});
27+28+async function makeSessionsDir(agentId = "main"): Promise<string> {
29+const sessionsDir = path.join(tmpDir, "agents", agentId, "sessions");
30+await fs.mkdir(sessionsDir, { recursive: true });
31+return sessionsDir;
32+}
33+34+async function writeStore(sessionsDir: string, store: Record<string, SessionEntry>): Promise<void> {
35+await fs.writeFile(path.join(sessionsDir, "sessions.json"), JSON.stringify(store, null, 2));
36+}
37+38+async function writeTranscript(
39+sessionsDir: string,
40+sessionId: string,
41+messages: unknown[],
42+): Promise<void> {
43+const lines = messages.map((message) => JSON.stringify({ message })).join("\n");
44+await fs.writeFile(path.join(sessionsDir, `${sessionId}.jsonl`), `${lines}\n`);
45+}
46+47+function cleanedLock(sessionsDir: string, sessionId: string): SessionLockInspection {
48+return {
49+lockPath: path.join(sessionsDir, `${sessionId}.jsonl.lock`),
50+pid: 999_999,
51+pidAlive: false,
52+createdAt: new Date(Date.now() - 1_000).toISOString(),
53+ageMs: 1_000,
54+stale: true,
55+staleReasons: ["dead-pid"],
56+removed: true,
57+};
58+}
59+60+describe("main-session-restart-recovery", () => {
61+it("marks only main running sessions whose transcript lock was cleaned", async () => {
62+const sessionsDir = await makeSessionsDir();
63+await writeStore(sessionsDir, {
64+"agent:main:main": {
65+sessionId: "main-session",
66+updatedAt: Date.now() - 10_000,
67+status: "running",
68+},
69+"agent:main:subagent:child": {
70+sessionId: "child-session",
71+updatedAt: Date.now() - 10_000,
72+status: "running",
73+spawnDepth: 1,
74+},
75+"agent:main:other": {
76+sessionId: "other-session",
77+updatedAt: Date.now() - 10_000,
78+status: "running",
79+},
80+});
81+82+const result = await markRestartAbortedMainSessionsFromLocks({
83+ sessionsDir,
84+cleanedLocks: [
85+cleanedLock(sessionsDir, "main-session"),
86+cleanedLock(sessionsDir, "child-session"),
87+],
88+});
89+90+const store = loadSessionStore(path.join(sessionsDir, "sessions.json"));
91+expect(result).toEqual({ marked: 1, skipped: 1 });
92+expect(store["agent:main:main"]?.abortedLastRun).toBe(true);
93+expect(store["agent:main:subagent:child"]?.abortedLastRun).toBeUndefined();
94+expect(store["agent:main:other"]?.abortedLastRun).toBeUndefined();
95+});
96+97+it("resumes marked sessions with a tool-result transcript tail", async () => {
98+const sessionsDir = await makeSessionsDir();
99+await writeStore(sessionsDir, {
100+"agent:main:main": {
101+sessionId: "main-session",
102+updatedAt: Date.now() - 10_000,
103+status: "running",
104+abortedLastRun: true,
105+},
106+});
107+await writeTranscript(sessionsDir, "main-session", [
108+{ role: "user", content: "run the tool" },
109+{ role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "exec" }] },
110+{ role: "toolResult", content: "done" },
111+]);
112+113+const result = await recoverRestartAbortedMainSessions({ stateDir: tmpDir });
114+115+expect(result).toEqual({ recovered: 1, failed: 0, skipped: 0 });
116+expect(callGateway).toHaveBeenCalledOnce();
117+expect(vi.mocked(callGateway).mock.calls[0]?.[0].params).toMatchObject({
118+sessionKey: "agent:main:main",
119+deliver: false,
120+lane: "main",
121+});
122+const store = loadSessionStore(path.join(sessionsDir, "sessions.json"));
123+expect(store["agent:main:main"]?.abortedLastRun).toBe(false);
124+});
125+126+it("does not scan ordinary running sessions without the restart-aborted marker", async () => {
127+const sessionsDir = await makeSessionsDir();
128+await writeStore(sessionsDir, {
129+"agent:main:main": {
130+sessionId: "main-session",
131+updatedAt: Date.now() - 10_000,
132+status: "running",
133+},
134+});
135+await writeTranscript(sessionsDir, "main-session", [
136+{ role: "user", content: "current process owns this" },
137+{ role: "toolResult", content: "done" },
138+]);
139+140+const result = await recoverRestartAbortedMainSessions({ stateDir: tmpDir });
141+142+expect(result).toEqual({ recovered: 0, failed: 0, skipped: 0 });
143+expect(callGateway).not.toHaveBeenCalled();
144+});
145+146+it("fails marked sessions whose transcript tail cannot be resumed", async () => {
147+const sessionsDir = await makeSessionsDir();
148+await writeStore(sessionsDir, {
149+"agent:main:main": {
150+sessionId: "main-session",
151+updatedAt: Date.now() - 10_000,
152+status: "running",
153+abortedLastRun: true,
154+},
155+});
156+await writeTranscript(sessionsDir, "main-session", [
157+{ role: "user", content: "hello" },
158+{ role: "assistant", content: "partial answer" },
159+]);
160+161+const result = await recoverRestartAbortedMainSessions({ stateDir: tmpDir });
162+163+expect(result).toEqual({ recovered: 0, failed: 1, skipped: 0 });
164+expect(callGateway).not.toHaveBeenCalled();
165+const store = loadSessionStore(path.join(sessionsDir, "sessions.json"));
166+expect(store["agent:main:main"]?.status).toBe("failed");
167+expect(store["agent:main:main"]?.abortedLastRun).toBe(true);
168+});
169+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。