diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c23f4b6dd9..d15d7ab5bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,7 @@ Docs: https://docs.openclaw.ai - Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so `openclaw doctor browser` and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc. - Browser/CDP: normalize trailing-dot localhost absolute-form hosts before loopback checks so remote CDP websocket URLs like `ws://localhost.:...` rewrite back to the configured remote host. (#59236) Thanks @mappel-nv. - Browser/attach-only profiles: disconnect cached Playwright CDP sessions when stopping attach-only or remote CDP profiles, while still reporting never-started local managed profiles as not stopped. (#60097) Thanks @pedh. +- Browser/task cleanup: close tracked browser tabs and best-effort browser processes when cron-isolated agents and subagents finish, so background browser runs stop leaking orphaned sessions. (#60146) Thanks @BrianWang1990. - Agents/output sanitization: strip namespaced `antml:thinking` blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus. - Kimi Coding/tools: normalize Anthropic tool payloads into the OpenAI-compatible function shape Kimi Coding expects so tool calls stop losing required arguments. (#59440) Thanks @obviyus. - Image tool/paths: resolve relative local media paths against the agent `workspaceDir` instead of `process.cwd()` so inputs like `inbox/receipt.png` pass the local-path allowlist reliably. (#57222) Thanks Priyansh Gupta. diff --git a/src/agents/subagent-registry-lifecycle.test.ts b/src/agents/subagent-registry-lifecycle.test.ts index 4589408e555..832f2400e27 100644 --- a/src/agents/subagent-registry-lifecycle.test.ts +++ b/src/agents/subagent-registry-lifecycle.test.ts @@ -17,6 +17,10 @@ const lifecycleEventMocks = vi.hoisted(() => ({ emitSessionLifecycleEvent: vi.fn(), })); +const browserMaintenanceMocks = vi.hoisted(() => ({ + closeTrackedBrowserTabsForSessions: vi.fn(async () => 0), +})); + vi.mock("../tasks/task-executor.js", () => ({ completeTaskRunByRunId: taskExecutorMocks.completeTaskRunByRunId, failTaskRunByRunId: taskExecutorMocks.failTaskRunByRunId, @@ -27,6 +31,10 @@ vi.mock("../sessions/session-lifecycle-events.js", () => ({ emitSessionLifecycleEvent: lifecycleEventMocks.emitSessionLifecycleEvent, })); +vi.mock("../plugin-sdk/browser-maintenance.js", () => ({ + closeTrackedBrowserTabsForSessions: browserMaintenanceMocks.closeTrackedBrowserTabsForSessions, +})); + vi.mock("./subagent-registry-helpers.js", async () => { const actual = await vi.importActual( "./subagent-registry-helpers.js", @@ -58,6 +66,7 @@ describe("subagent registry lifecycle hardening", () => { beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); + browserMaintenanceMocks.closeTrackedBrowserTabsForSessions.mockClear(); mod = await import("./subagent-registry-lifecycle.js"); }); @@ -164,4 +173,86 @@ describe("subagent registry lifecycle hardening", () => { expect(entry.cleanupCompletedAt).toBeTypeOf("number"); expect(persist).toHaveBeenCalled(); }); + + it("cleans up tracked browser sessions before subagent cleanup flow", async () => { + const persist = vi.fn(); + const entry = createRunEntry({ + expectsCompletionMessage: false, + }); + const runSubagentAnnounceFlow = vi.fn(async () => true); + + const controller = mod.createSubagentRegistryLifecycleController({ + runs: new Map([[entry.runId, entry]]), + resumedRuns: new Set(), + subagentAnnounceTimeoutMs: 1_000, + persist, + clearPendingLifecycleError: vi.fn(), + countPendingDescendantRuns: () => 0, + suppressAnnounceForSteerRestart: () => false, + shouldEmitEndedHookForRun: () => false, + emitSubagentEndedHookForRun: vi.fn(async () => {}), + notifyContextEngineSubagentEnded: vi.fn(async () => {}), + resumeSubagentRun: vi.fn(), + captureSubagentCompletionReply: vi.fn(async () => "final completion reply"), + runSubagentAnnounceFlow, + warn: vi.fn(), + }); + + await expect( + controller.completeSubagentRun({ + runId: entry.runId, + endedAt: 4_000, + outcome: { status: "ok" }, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + triggerCleanup: true, + }), + ).resolves.toBeUndefined(); + + expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith({ + sessionKeys: [entry.childSessionKey], + onWarn: expect.any(Function), + }); + expect(runSubagentAnnounceFlow).toHaveBeenCalledWith( + expect.objectContaining({ + childSessionKey: entry.childSessionKey, + }), + ); + }); + + it("skips browser cleanup when steer restart suppresses cleanup flow", async () => { + const entry = createRunEntry({ + expectsCompletionMessage: false, + }); + const runSubagentAnnounceFlow = vi.fn(async () => true); + + const controller = mod.createSubagentRegistryLifecycleController({ + runs: new Map([[entry.runId, entry]]), + resumedRuns: new Set(), + subagentAnnounceTimeoutMs: 1_000, + persist: vi.fn(), + clearPendingLifecycleError: vi.fn(), + countPendingDescendantRuns: () => 0, + suppressAnnounceForSteerRestart: () => true, + shouldEmitEndedHookForRun: () => false, + emitSubagentEndedHookForRun: vi.fn(async () => {}), + notifyContextEngineSubagentEnded: vi.fn(async () => {}), + resumeSubagentRun: vi.fn(), + captureSubagentCompletionReply: vi.fn(async () => "final completion reply"), + runSubagentAnnounceFlow, + warn: vi.fn(), + }); + + await expect( + controller.completeSubagentRun({ + runId: entry.runId, + endedAt: 4_000, + outcome: { status: "ok" }, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + triggerCleanup: true, + }), + ).resolves.toBeUndefined(); + + expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).not.toHaveBeenCalled(); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + }); }); diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 6a2e80b3ea5..091117f97bc 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -12,12 +12,14 @@ const { loadConfigMock, fetchWithSsrFGuardMock, runCronIsolatedAgentTurnMock, + closeTrackedBrowserTabsForSessionsMock, } = vi.hoisted(() => ({ enqueueSystemEventMock: vi.fn(), requestHeartbeatNowMock: vi.fn(), loadConfigMock: vi.fn(), fetchWithSsrFGuardMock: vi.fn(), runCronIsolatedAgentTurnMock: vi.fn(async () => ({ status: "ok" as const, summary: "ok" })), + closeTrackedBrowserTabsForSessionsMock: vi.fn(async () => 0), })); function enqueueSystemEvent(...args: unknown[]) { @@ -59,6 +61,10 @@ vi.mock("../cron/isolated-agent.js", () => ({ runCronIsolatedAgentTurn: runCronIsolatedAgentTurnMock, })); +vi.mock("../plugin-sdk/browser-maintenance.js", () => ({ + closeTrackedBrowserTabsForSessions: closeTrackedBrowserTabsForSessionsMock, +})); + import { buildGatewayCronService } from "./server-cron.js"; function createCronConfig(name: string): OpenClawConfig { @@ -80,6 +86,7 @@ describe("buildGatewayCronService", () => { loadConfigMock.mockClear(); fetchWithSsrFGuardMock.mockClear(); runCronIsolatedAgentTurnMock.mockClear(); + closeTrackedBrowserTabsForSessionsMock.mockClear(); }); it("routes main-target jobs to the scoped session for enqueue + wake", async () => { @@ -200,6 +207,10 @@ describe("buildGatewayCronService", () => { sessionKey: "project-alpha-monitor", }), ); + expect(closeTrackedBrowserTabsForSessionsMock).toHaveBeenCalledWith({ + sessionKeys: ["project-alpha-monitor"], + onWarn: expect.any(Function), + }); } finally { state.cron.stop(); }