






















1+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+import type { OpenClawConfig } from "../config/types.openclaw.js";
3+4+// Regression coverage for #57790: the bounded shutdown drain must fire a
5+// typed `session_end` for every session the tracker has noted, must skip
6+// sessions that have already been finalized through replace / reset /
7+// delete / compaction (so we never double-fire), must respect the
8+// configured total timeout, and must propagate the reason ("shutdown" or
9+// "restart") into the plugin hook payload.
10+11+const runSessionEndMock = vi.fn(async () => undefined);
12+const hasHooksMock = vi.fn((name: string) => name === "session_end");
13+const getGlobalHookRunnerMock = vi.fn(() => ({
14+hasHooks: hasHooksMock,
15+runSessionEnd: runSessionEndMock,
16+runSessionStart: vi.fn(async () => undefined),
17+}));
18+19+vi.mock("../plugins/hook-runner-global.js", () => ({
20+getGlobalHookRunner: getGlobalHookRunnerMock,
21+}));
22+23+vi.mock("./session-transcript-files.fs.js", () => ({
24+resolveStableSessionEndTranscript: vi.fn(() => ({
25+sessionFile: undefined,
26+transcriptArchived: false,
27+})),
28+archiveSessionTranscriptsDetailed: vi.fn(() => []),
29+}));
30+31+vi.mock("../auto-reply/reply/session-hooks.js", () => ({
32+buildSessionEndHookPayload: vi.fn(
33+(params: { sessionId: string; reason: string; sessionKey: string }) => ({
34+event: { sessionId: params.sessionId, reason: params.reason, sessionKey: params.sessionKey },
35+context: { sessionId: params.sessionId, reason: params.reason },
36+}),
37+),
38+buildSessionStartHookPayload: vi.fn(() => ({ event: {}, context: {} })),
39+}));
40+41+const {
42+ drainActiveSessionsForShutdown,
43+ emitGatewaySessionEndPluginHook,
44+ emitGatewaySessionStartPluginHook,
45+} = await import("./session-reset-service.js");
46+const { clearActiveSessionsForShutdownTracker, listActiveSessionsForShutdown } =
47+await import("./active-sessions-shutdown-tracker.js");
48+49+const cfg: OpenClawConfig = {};
50+51+beforeEach(() => {
52+clearActiveSessionsForShutdownTracker();
53+runSessionEndMock.mockClear();
54+hasHooksMock.mockClear();
55+hasHooksMock.mockImplementation((name: string) => name === "session_end");
56+});
57+58+afterEach(() => {
59+clearActiveSessionsForShutdownTracker();
60+});
61+62+describe("drainActiveSessionsForShutdown", () => {
63+it("returns an empty result and skips hook emission when no sessions are tracked", async () => {
64+const result = await drainActiveSessionsForShutdown({ reason: "shutdown" });
65+66+expect(result).toEqual({ emittedSessionIds: [], timedOut: false });
67+expect(runSessionEndMock).not.toHaveBeenCalled();
68+});
69+70+it("fires session_end with reason=shutdown for every tracked session and clears them", async () => {
71+emitGatewaySessionStartPluginHook({
72+ cfg,
73+sessionKey: "agent:main:main",
74+sessionId: "sess-A",
75+storePath: "/tmp/store.json",
76+});
77+emitGatewaySessionStartPluginHook({
78+ cfg,
79+sessionKey: "agent:main:other",
80+sessionId: "sess-B",
81+storePath: "/tmp/store.json",
82+});
83+84+const result = await drainActiveSessionsForShutdown({ reason: "shutdown" });
85+86+expect(result.timedOut).toBe(false);
87+expect(result.emittedSessionIds.sort()).toEqual(["sess-A", "sess-B"]);
88+expect(runSessionEndMock).toHaveBeenCalledTimes(2);
89+const reasons = runSessionEndMock.mock.calls.map(
90+([event]) => (event as { reason?: string }).reason,
91+);
92+expect(reasons.every((reason) => reason === "shutdown")).toBe(true);
93+// After the drain, the tracker forgets every emitted session (the emit
94+// helper calls `forgetActiveSessionForShutdown`), so a second drain is a
95+// no-op and we never double-fire on restart loops.
96+expect(listActiveSessionsForShutdown()).toEqual([]);
97+});
98+99+it("propagates reason=restart when called for a restart shutdown", async () => {
100+emitGatewaySessionStartPluginHook({
101+ cfg,
102+sessionKey: "agent:main:main",
103+sessionId: "sess-A",
104+storePath: "/tmp/store.json",
105+});
106+107+await drainActiveSessionsForShutdown({ reason: "restart" });
108+109+expect(runSessionEndMock).toHaveBeenCalledTimes(1);
110+expect((runSessionEndMock.mock.calls[0][0] as { reason?: string }).reason).toBe("restart");
111+});
112+113+it("does not double-fire for a session already finalized by reset/delete/compaction", async () => {
114+emitGatewaySessionStartPluginHook({
115+ cfg,
116+sessionKey: "agent:main:main",
117+sessionId: "sess-A",
118+storePath: "/tmp/store.json",
119+});
120+emitGatewaySessionStartPluginHook({
121+ cfg,
122+sessionKey: "agent:main:other",
123+sessionId: "sess-B",
124+storePath: "/tmp/store.json",
125+});
126+// Simulate sess-A being finalized through the normal reset path before
127+// the gateway is shut down: the matching `session_end` is fired with
128+// reason="reset" and the tracker forgets it.
129+emitGatewaySessionEndPluginHook({
130+ cfg,
131+sessionKey: "agent:main:main",
132+sessionId: "sess-A",
133+storePath: "/tmp/store.json",
134+reason: "reset",
135+});
136+runSessionEndMock.mockClear();
137+138+await drainActiveSessionsForShutdown({ reason: "shutdown" });
139+140+expect(runSessionEndMock).toHaveBeenCalledTimes(1);
141+expect((runSessionEndMock.mock.calls[0][0] as { sessionId?: string }).sessionId).toBe("sess-B");
142+});
143+144+it("still records the session as forgotten when no `session_end` plugins are registered", async () => {
145+hasHooksMock.mockImplementation(() => false);
146+emitGatewaySessionStartPluginHook({
147+ cfg,
148+sessionKey: "agent:main:main",
149+sessionId: "sess-A",
150+storePath: "/tmp/store.json",
151+});
152+// session_end fires while no plugin listens: hook is not run, but the
153+// shutdown tracker must still forget the session so the later drain
154+// does not pick it up.
155+emitGatewaySessionEndPluginHook({
156+ cfg,
157+sessionKey: "agent:main:main",
158+sessionId: "sess-A",
159+storePath: "/tmp/store.json",
160+reason: "deleted",
161+});
162+163+expect(listActiveSessionsForShutdown()).toEqual([]);
164+const result = await drainActiveSessionsForShutdown({ reason: "shutdown" });
165+166+expect(result.emittedSessionIds).toEqual([]);
167+});
168+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。