
























@@ -1,7 +1,9 @@
11import fs from "node:fs/promises";
2+import path from "node:path";
23import { expect, test, vi } from "vitest";
4+import type { SessionAcpMeta } from "../config/sessions/types.js";
35import { enqueueSystemEvent, peekSystemEvents } from "../infra/system-events.js";
4-import { embeddedRunMock, writeSessionStore } from "./test-helpers.js";
6+import { embeddedRunMock, testState, writeSessionStore } from "./test-helpers.js";
57import {
68setupGatewaySessionsTestHarness,
79bootstrapCacheMocks,
@@ -231,6 +233,232 @@ test("sessions.reset closes ACP runtime handles for ACP sessions", async () => {
231233expectResetAcpState(store["agent:main:main"]?.acp);
232234});
233235236+test("sessions.reset closes child ACP runtime handles spawned from the parent", async () => {
237+const { dir } = await createSessionStoreDir();
238+await writeSingleLineSession(dir, "sess-main", "hello");
239+const prepareFreshSession = vi.fn(async () => {});
240+acpRuntimeMocks.getAcpRuntimeBackend.mockReturnValue({
241+id: "acpx",
242+runtime: {
243+ prepareFreshSession,
244+},
245+});
246+247+await writeSessionStore({
248+entries: {
249+main: sessionStoreEntry("sess-main", {
250+acp: {
251+backend: "acpx",
252+agent: "codex",
253+runtimeSessionName: "runtime:reset",
254+identity: {
255+state: "resolved",
256+acpxRecordId: "agent:main:main",
257+acpxSessionId: "backend-session-main",
258+source: "status",
259+lastUpdatedAt: Date.now(),
260+},
261+mode: "persistent",
262+cwd: "/tmp/acp-session",
263+state: "idle",
264+lastActivityAt: Date.now(),
265+},
266+}),
267+"acp-child-1": sessionStoreEntry("sess-child-1", {
268+spawnedBy: "agent:main:main",
269+acp: {
270+backend: "acpx",
271+agent: "codex",
272+runtimeSessionName: "runtime:child-1",
273+identity: {
274+state: "resolved",
275+acpxRecordId: "agent:main:acp-child-1",
276+acpxSessionId: "backend-session-child-1",
277+source: "status",
278+lastUpdatedAt: Date.now(),
279+},
280+mode: "oneshot",
281+cwd: "/tmp/acp-session",
282+state: "idle",
283+lastActivityAt: Date.now(),
284+},
285+}),
286+"not-acp-child": sessionStoreEntry("sess-not-acp-child", {
287+spawnedBy: "agent:main:main",
288+}),
289+"unrelated-acp-child": sessionStoreEntry("sess-unrelated-acp-child", {
290+spawnedBy: "agent:main:other",
291+acp: {
292+backend: "acpx",
293+agent: "codex",
294+runtimeSessionName: "runtime:unrelated",
295+mode: "oneshot",
296+cwd: "/tmp/acp-session",
297+state: "idle",
298+lastActivityAt: Date.now(),
299+},
300+}),
301+},
302+});
303+304+const reset = await directSessionReq<{ ok: true }>("sessions.reset", {
305+key: "main",
306+});
307+expect(reset.ok).toBe(true);
308+309+// The parent and its spawned ACP child are both closed; without child cleanup
310+// the child's claude-agent-acp process is orphaned on parent reset (#68916).
311+const closedKeys = (
312+acpManagerMocks.closeSession.mock.calls as unknown as Array<[{ sessionKey?: string }]>
313+).map((call) => call[0]?.sessionKey);
314+expect(closedKeys).toContain("agent:main:main");
315+expect(closedKeys).toContain("agent:main:acp-child-1");
316+expect(closedKeys).not.toContain("agent:main:not-acp-child");
317+expect(closedKeys).not.toContain("agent:main:unrelated-acp-child");
318+});
319+320+test("sessions.reset closes a spawned ACP child that lives in a different agent store", async () => {
321+const stateDir = process.env.OPENCLAW_STATE_DIR;
322+if (!stateDir) {
323+throw new Error("OPENCLAW_STATE_DIR is required for gateway session tests");
324+}
325+// Per-agent store layout: ACP children live under the target agent's own
326+// store file, which is different from the parent's store.
327+testState.sessionConfig = {
328+store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
329+};
330+const mainStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
331+const codexStorePath = path.join(stateDir, "agents", "codex", "sessions", "sessions.json");
332+await fs.mkdir(path.dirname(mainStorePath), { recursive: true });
333+await fs.mkdir(path.dirname(codexStorePath), { recursive: true });
334+await fs.writeFile(
335+mainStorePath,
336+JSON.stringify({
337+main: {
338+sessionId: "sess-main",
339+updatedAt: Date.now(),
340+acp: {
341+backend: "acpx",
342+agent: "codex",
343+runtimeSessionName: "runtime:main",
344+mode: "persistent",
345+state: "idle",
346+lastActivityAt: Date.now(),
347+},
348+},
349+}),
350+"utf-8",
351+);
352+await fs.writeFile(
353+codexStorePath,
354+JSON.stringify({
355+"agent:codex:acp:cross-store-child": {
356+sessionId: "sess-codex-child",
357+updatedAt: Date.now(),
358+spawnedBy: "agent:main:main",
359+acp: {
360+backend: "acpx",
361+agent: "codex",
362+runtimeSessionName: "runtime:codex-child",
363+mode: "oneshot",
364+state: "idle",
365+lastActivityAt: Date.now(),
366+},
367+},
368+}),
369+"utf-8",
370+);
371+372+const reset = await directSessionReq<{ ok: true }>("sessions.reset", { key: "main" });
373+expect(reset.ok).toBe(true);
374+375+// The child in the codex store is closed even though it is not in the main
376+// (parent) store — cleanup enumerates the combined cross-agent store.
377+const closedKeys = (
378+acpManagerMocks.closeSession.mock.calls as unknown as Array<[{ sessionKey?: string }]>
379+).map((call) => call[0]?.sessionKey);
380+expect(closedKeys).toContain("agent:codex:acp:cross-store-child");
381+});
382+383+test("sessions.reset closes child ACP runtimes concurrently so stuck children do not serialize cleanup", async () => {
384+const { dir } = await createSessionStoreDir();
385+await writeSingleLineSession(dir, "sess-main", "hello");
386+acpRuntimeMocks.getAcpRuntimeBackend.mockReturnValue({
387+id: "acpx",
388+runtime: { prepareFreshSession: vi.fn(async () => {}) },
389+});
390+391+const childAcp = (recordId: string): SessionAcpMeta => ({
392+backend: "acpx",
393+agent: "codex",
394+runtimeSessionName: `runtime:${recordId}`,
395+identity: {
396+state: "resolved",
397+acpxRecordId: recordId,
398+acpxSessionId: `backend-${recordId}`,
399+source: "status",
400+lastUpdatedAt: Date.now(),
401+},
402+mode: "oneshot",
403+cwd: "/tmp/acp-session",
404+state: "idle",
405+lastActivityAt: Date.now(),
406+});
407+408+await writeSessionStore({
409+entries: {
410+main: sessionStoreEntry("sess-main", { acp: childAcp("agent:main:main") }),
411+// Mix the two real lineage fields: ACP spawns record `spawnedBy`,
412+// subagent spawns record `parentSessionKey`; both must be cleaned up.
413+"acp-child-1": sessionStoreEntry("sess-c1", {
414+spawnedBy: "agent:main:main",
415+acp: childAcp("agent:main:acp-child-1"),
416+}),
417+"acp-child-2": sessionStoreEntry("sess-c2", {
418+spawnedBy: "agent:main:main",
419+acp: childAcp("agent:main:acp-child-2"),
420+}),
421+"acp-child-3": sessionStoreEntry("sess-c3", {
422+parentSessionKey: "agent:main:main",
423+acp: childAcp("agent:main:acp-child-3"),
424+}),
425+},
426+});
427+428+// Parent cancel resolves immediately; child cancels hang until released. With
429+// sequential cleanup only the first child would dispatch; concurrent cleanup
430+// dispatches all three before any resolves.
431+const releaseChildren: Array<() => void> = [];
432+acpManagerMocks.cancelSession.mockImplementation(async (...args: unknown[]) => {
433+const req = args[0] as { sessionKey?: string } | undefined;
434+if (req?.sessionKey === "agent:main:main") {
435+return;
436+}
437+await new Promise<void>((resolve) => releaseChildren.push(resolve));
438+});
439+440+try {
441+const resetPromise = directSessionReq<{ ok: true }>("sessions.reset", {
442+key: "main",
443+});
444+445+await vi.waitFor(() => {
446+const childCancels = (
447+acpManagerMocks.cancelSession.mock.calls as unknown as Array<[{ sessionKey?: string }]>
448+).filter((call) => call[0]?.sessionKey?.startsWith("agent:main:acp-child"));
449+expect(childCancels.length).toBe(3);
450+});
451+452+for (const release of releaseChildren) {
453+release();
454+}
455+const reset = await resetPromise;
456+expect(reset.ok).toBe(true);
457+} finally {
458+acpManagerMocks.cancelSession.mockImplementation(async () => {});
459+}
460+});
461+234462test("sessions.reset does not emit lifecycle events when key does not exist", async () => {
235463const { dir } = await createSessionStoreDir();
236464await writeSingleLineSession(dir, "sess-main", "hello");
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。