From 0c04253f0e9aed83bd6a2e0c448cc9eb769fc5d6 Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 1 May 2026 20:43:20 +0700 Subject: [PATCH] fix: preserve terminal cleanup expiry markers --- .../run-context-lifecycle.contract.test.ts | 75 +++++++++++++++++++ src/plugins/host-hook-runtime.ts | 1 - 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/plugins/contracts/run-context-lifecycle.contract.test.ts b/src/plugins/contracts/run-context-lifecycle.contract.test.ts index 16670ea3c18..56935b80696 100644 --- a/src/plugins/contracts/run-context-lifecycle.contract.test.ts +++ b/src/plugins/contracts/run-context-lifecycle.contract.test.ts @@ -242,6 +242,81 @@ describe("plugin run context lifecycle", () => { ).toBeUndefined(); }); + it("keeps the expired terminal marker across repeated terminal events", async () => { + vi.useFakeTimers(); + let releaseFirstTerminalHandler: (() => void) | undefined; + let firstTerminalHandlerWroteContext: unknown; + let secondTerminalHandlerWroteContext: unknown; + let terminalEventsSeen = 0; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "repeated-terminal-subscription", + name: "Repeated Terminal Subscription", + }), + register(api) { + api.registerAgentEventSubscription({ + id: "repeat-terminal", + streams: ["lifecycle"], + async handle(event, ctx) { + if (event.data?.phase !== "end") { + return; + } + terminalEventsSeen += 1; + if (terminalEventsSeen === 1) { + await new Promise((resolve) => { + releaseFirstTerminalHandler = resolve; + }); + ctx.setRunContext("terminal", { from: "first" }); + firstTerminalHandlerWroteContext = ctx.getRunContext("terminal"); + return; + } + ctx.setRunContext("terminal", { from: "second" }); + secondTerminalHandlerWroteContext = ctx.getRunContext("terminal"); + }, + }); + }, + }); + setActivePluginRegistry(registry.registry); + + emitAgentEvent({ + runId: "run-repeat-terminal", + stream: "lifecycle", + data: { phase: "end" }, + }); + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS); + + emitAgentEvent({ + runId: "run-repeat-terminal", + stream: "lifecycle", + data: { phase: "end" }, + }); + await vi.advanceTimersByTimeAsync(0); + + expect(secondTerminalHandlerWroteContext).toBeUndefined(); + expect( + getPluginRunContext({ + pluginId: "repeated-terminal-subscription", + get: { runId: "run-repeat-terminal", namespace: "terminal" }, + }), + ).toBeUndefined(); + + releaseFirstTerminalHandler?.(); + await vi.advanceTimersByTimeAsync(0); + + expect(firstTerminalHandlerWroteContext).toBeUndefined(); + expect( + getPluginRunContext({ + pluginId: "repeated-terminal-subscription", + get: { runId: "run-repeat-terminal", namespace: "terminal" }, + }), + ).toBeUndefined(); + }); + it("preserves scheduler jobs instead of invoking stale cleanup callbacks", async () => { const cleanup = vi.fn(); registerPluginSessionSchedulerJob({ diff --git a/src/plugins/host-hook-runtime.ts b/src/plugins/host-hook-runtime.ts index 12ef63143e8..2bde1dba413 100644 --- a/src/plugins/host-hook-runtime.ts +++ b/src/plugins/host-hook-runtime.ts @@ -59,7 +59,6 @@ function copyJsonValue(value: PluginJsonValue): PluginJsonValue { function markPluginRunClosed(runId: string): void { const state = getPluginHostRuntimeState(); - state.terminalEventCleanupExpiredRunIds.delete(runId); state.closedRunIds.delete(runId); state.closedRunIds.add(runId); while (state.closedRunIds.size > CLOSED_RUN_IDS_MAX) {