Download src/plugins/contracts/run-context-lifecycle.contract.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 30.4 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/plugins/contracts/run-context-lifecycle.contract.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/plugins/contracts/run-context-lifecycle.contract.test.ts
-
curl -L -o run-context-lifecycle.contract.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/plugins/contracts/run-context-lifecycle.contract.test.ts
30.4 kB
| // Run context lifecycle contract tests cover plugin run context setup and cleanup. | |
| import path from "node:path"; | |
| import { | |
| createPluginRegistryFixture, | |
| registerTestPlugin, | |
| } from "openclaw/plugin-sdk/plugin-test-contracts"; | |
| import { afterEach, describe, expect, it, vi } from "vitest"; | |
| import { withTempConfig } from "../../gateway/test-temp-config.js"; | |
| import { emitAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; | |
| import { loadSessionStore, updateSessionStore } from "../../plugin-sdk/session-store-runtime.js"; | |
| import { createDeferredCore } from "../../shared/deferred.js"; | |
| import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; | |
| import { runPluginHostCleanup } from "../host-hook-cleanup.js"; | |
| import { | |
| clearPluginHostRuntimeState, | |
| getPluginRunContext, | |
| dispatchPluginAgentEventSubscriptions, | |
| registerPluginSessionSchedulerJob, | |
| setPluginRunContext, | |
| } from "../host-hook-runtime.js"; | |
| import { | |
| listPluginSessionSchedulerJobs, | |
| PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS, | |
| } from "../host-hook-runtime.test-fixtures.js"; | |
| import { runPluginRegisterSyncInRegistry } from "../loader-module-runtime.js"; | |
| import { createEmptyPluginRegistry } from "../registry-empty.js"; | |
| import { | |
| captureActivePluginRegistrySnapshot, | |
| rollbackStagedPluginRegistry, | |
| setActivePluginRegistry, | |
| stageActivePluginRegistry, | |
| } from "../runtime.js"; | |
| import { createPluginRecord } from "../status.test-helpers.js"; | |
| import type { OpenClawPluginApi } from "../types.js"; | |
| const PLUGIN_HOST_CLEANUP_TIMEOUT_MS = 5_000; | |
| async function waitForPluginEventHandlers(): Promise<void> { | |
| await new Promise<void>((resolve) => { | |
| setImmediate(resolve); | |
| }); | |
| } | |
| function expectNoCleanupFailures(result: Awaited<ReturnType<typeof runPluginHostCleanup>>): void { | |
| expect(result.failures).toEqual([]); | |
| } | |
| function requireFailureByHookId( | |
| result: Awaited<ReturnType<typeof runPluginHostCleanup>>, | |
| hookId: string, | |
| ) { | |
| const failure = result.failures.find((entry) => entry.hookId === hookId); | |
| if (!failure) { | |
| throw new Error(`Expected cleanup failure for hook ${hookId}`); | |
| } | |
| return failure; | |
| } | |
| describe("plugin run context lifecycle", () => { | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| setActivePluginRegistry(createEmptyPluginRegistry()); | |
| clearPluginHostRuntimeState(); | |
| resetAgentEventsForTest(); | |
| }); | |
| it("keeps run-context APIs callable after registration closes", () => { | |
| const { config, registry } = createPluginRegistryFixture(); | |
| let capturedApi: OpenClawPluginApi | undefined; | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "late-run-context-plugin", | |
| name: "Late Run Context Plugin", | |
| }), | |
| register(api) { | |
| runPluginRegisterSyncInRegistry( | |
| (guardedApi) => { | |
| capturedApi = guardedApi; | |
| }, | |
| api, | |
| registry.registry, | |
| "late-run-context-plugin", | |
| ); | |
| }, | |
| }); | |
| setActivePluginRegistry(registry.registry); | |
| capturedApi?.registerGatewayMethod("late-run-context.blocked", () => {}); | |
| expect(Object.keys(registry.registry.gatewayHandlers)).not.toContain( | |
| "late-run-context.blocked", | |
| ); | |
| expect( | |
| capturedApi?.runContext.setRunContext({ | |
| runId: "late-run", | |
| namespace: "state", | |
| value: { available: true }, | |
| }), | |
| ).toBe(true); | |
| expect( | |
| capturedApi?.runContext.getRunContext({ | |
| runId: "late-run", | |
| namespace: "state", | |
| }), | |
| ).toEqual({ available: true }); | |
| capturedApi?.runContext.clearRunContext({ runId: "late-run", namespace: "state" }); | |
| expect( | |
| capturedApi?.runContext.getRunContext({ | |
| runId: "late-run", | |
| namespace: "state", | |
| }), | |
| ).toBeUndefined(); | |
| }); | |
| it("blocks stale plugin API run-context access after registry replacement", () => { | |
| const { config, registry } = createPluginRegistryFixture(); | |
| let capturedApi: OpenClawPluginApi | undefined; | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "stale-run-context-plugin", | |
| name: "Stale Run Context Plugin", | |
| }), | |
| register(api) { | |
| capturedApi = api; | |
| }, | |
| }); | |
| setActivePluginRegistry(registry.registry); | |
| setActivePluginRegistry(createEmptyPluginRegistry()); | |
| expect( | |
| capturedApi?.runContext?.setRunContext({ | |
| runId: "stale-run", | |
| namespace: "state", | |
| value: { stale: true }, | |
| }), | |
| ).toBe(false); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "stale-run-context-plugin", | |
| get: { runId: "stale-run", namespace: "state" }, | |
| }), | |
| ).toBeUndefined(); | |
| expect( | |
| setPluginRunContext({ | |
| pluginId: "stale-run-context-plugin", | |
| patch: { runId: "stale-run", namespace: "state", value: { live: true } }, | |
| }), | |
| ).toBe(true); | |
| expect(capturedApi?.getRunContext({ runId: "stale-run", namespace: "state" })).toBeUndefined(); | |
| expect( | |
| capturedApi?.runContext.getRunContext({ runId: "stale-run", namespace: "state" }), | |
| ).toBeUndefined(); | |
| capturedApi?.runContext?.clearRunContext({ runId: "stale-run", namespace: "state" }); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "stale-run-context-plugin", | |
| get: { runId: "stale-run", namespace: "state" }, | |
| }), | |
| ).toEqual({ live: true }); | |
| }); | |
| it("allows run-context mutations after a previous registry is restored active", () => { | |
| const { config, registry } = createPluginRegistryFixture(); | |
| let capturedApi: OpenClawPluginApi | undefined; | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "restored-run-context-plugin", | |
| name: "Restored Run Context Plugin", | |
| }), | |
| register(api) { | |
| capturedApi = api; | |
| }, | |
| }); | |
| setActivePluginRegistry(registry.registry); | |
| const snapshot = captureActivePluginRegistrySnapshot(); | |
| stageActivePluginRegistry(createEmptyPluginRegistry(), null, "default"); | |
| rollbackStagedPluginRegistry(snapshot); | |
| expect( | |
| capturedApi?.runContext?.setRunContext({ | |
| runId: "restored-run", | |
| namespace: "state", | |
| value: { restored: true }, | |
| }), | |
| ).toBe(true); | |
| expect( | |
| capturedApi?.runContext?.getRunContext({ | |
| runId: "restored-run", | |
| namespace: "state", | |
| }), | |
| ).toEqual({ restored: true }); | |
| }); | |
| it("allows run-context initialization during activating plugin registration", () => { | |
| const { config, registry } = createPluginRegistryFixture(); | |
| const record = createPluginRecord({ | |
| id: "registration-run-context-plugin", | |
| name: "Registration Run Context Plugin", | |
| }); | |
| const api = registry.createApi(record, { config }); | |
| runPluginRegisterSyncInRegistry( | |
| () => { | |
| expect(registry.registry.plugins).not.toContain(record); | |
| expect( | |
| api.setRunContext({ | |
| runId: "run-registration", | |
| namespace: "state", | |
| value: { initialized: true }, | |
| }), | |
| ).toBe(true); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "registration-run-context-plugin", | |
| get: { runId: "run-registration", namespace: "state" }, | |
| }), | |
| ).toEqual({ initialized: true }); | |
| api.clearRunContext({ runId: "run-registration", namespace: "state" }); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "registration-run-context-plugin", | |
| get: { runId: "run-registration", namespace: "state" }, | |
| }), | |
| ).toBeUndefined(); | |
| }, | |
| api, | |
| registry.registry, | |
| record.id, | |
| ); | |
| }); | |
| it("fences retired agent-event callbacks from successor run context", async () => { | |
| let releasePriorHandler: (() => void) | undefined; | |
| let markPriorHandlerStarted: (() => void) | undefined; | |
| let retiredHandlerSawContext: unknown; | |
| const priorHandlerStarted = new Promise<void>((resolve) => { | |
| markPriorHandlerStarted = resolve; | |
| }); | |
| const priorHandlerRelease = new Promise<void>((resolve) => { | |
| releasePriorHandler = resolve; | |
| }); | |
| const prior = createPluginRegistryFixture(); | |
| registerTestPlugin({ | |
| registry: prior.registry, | |
| config: prior.config, | |
| record: createPluginRecord({ | |
| id: "agent-event-generation", | |
| name: "Agent Event Generation", | |
| }), | |
| register(api) { | |
| api.registerAgentEventSubscription({ | |
| id: "prior", | |
| streams: ["tool"], | |
| async handle(event, ctx) { | |
| if (event.data.name !== "hold") { | |
| return; | |
| } | |
| markPriorHandlerStarted?.(); | |
| await priorHandlerRelease; | |
| retiredHandlerSawContext = ctx.getRunContext("state"); | |
| ctx.setRunContext("state", { generation: "A" }); | |
| ctx.clearRunContext("preserved"); | |
| }, | |
| }); | |
| }, | |
| }); | |
| const successor = createPluginRegistryFixture(); | |
| registerTestPlugin({ | |
| registry: successor.registry, | |
| config: successor.config, | |
| record: createPluginRecord({ | |
| id: "agent-event-generation", | |
| name: "Agent Event Generation", | |
| }), | |
| register(api) { | |
| api.registerAgentEventSubscription({ | |
| id: "successor", | |
| streams: ["tool"], | |
| handle(event, ctx) { | |
| if (event.data.name !== "successor") { | |
| return; | |
| } | |
| ctx.setRunContext("state", { generation: "B" }); | |
| ctx.setRunContext("preserved", { generation: "B" }); | |
| }, | |
| }); | |
| }, | |
| }); | |
| setActivePluginRegistry(prior.registry.registry); | |
| emitAgentEvent({ | |
| runId: "run-agent-event-generation", | |
| stream: "tool", | |
| data: { name: "hold" }, | |
| }); | |
| await priorHandlerStarted; | |
| setActivePluginRegistry(successor.registry.registry); | |
| emitAgentEvent({ | |
| runId: "run-agent-event-generation", | |
| stream: "tool", | |
| data: { name: "successor" }, | |
| }); | |
| await waitForPluginEventHandlers(); | |
| // Restoring the same registry object must not revive callbacks admitted before cutover. | |
| setActivePluginRegistry(prior.registry.registry); | |
| releasePriorHandler?.(); | |
| await waitForPluginEventHandlers(); | |
| expect(retiredHandlerSawContext).toBeUndefined(); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "agent-event-generation", | |
| get: { runId: "run-agent-event-generation", namespace: "state" }, | |
| }), | |
| ).toEqual({ generation: "B" }); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "agent-event-generation", | |
| get: { runId: "run-agent-event-generation", namespace: "preserved" }, | |
| }), | |
| ).toEqual({ generation: "B" }); | |
| }); | |
| it("does not let delayed non-terminal subscriptions resurrect closed run context", async () => { | |
| let releaseToolHandler: (() => void) | undefined; | |
| let delayedToolHandlerSawContext: unknown; | |
| const { config, registry } = createPluginRegistryFixture(); | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "delayed-subscription", | |
| name: "Delayed Subscription", | |
| }), | |
| register(api) { | |
| api.registerAgentEventSubscription({ | |
| id: "delayed", | |
| streams: ["tool"], | |
| async handle(eventValue, ctx) { | |
| ctx.setRunContext("before-terminal", { visible: true }); | |
| await new Promise<void>((resolve) => { | |
| releaseToolHandler = resolve; | |
| }); | |
| delayedToolHandlerSawContext = ctx.getRunContext("before-terminal"); | |
| ctx.setRunContext("late", { resurrected: true }); | |
| }, | |
| }); | |
| }, | |
| }); | |
| setActivePluginRegistry(registry.registry); | |
| emitAgentEvent({ | |
| runId: "run-delayed-subscription", | |
| stream: "tool", | |
| data: { name: "tool" }, | |
| }); | |
| await Promise.resolve(); | |
| emitAgentEvent({ | |
| runId: "run-delayed-subscription", | |
| stream: "lifecycle", | |
| data: { phase: "end" }, | |
| }); | |
| await Promise.resolve(); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "delayed-subscription", | |
| get: { runId: "run-delayed-subscription", namespace: "before-terminal" }, | |
| }), | |
| ).toEqual({ visible: true }); | |
| releaseToolHandler?.(); | |
| await waitForPluginEventHandlers(); | |
| expect(delayedToolHandlerSawContext).toEqual({ visible: true }); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "delayed-subscription", | |
| get: { runId: "run-delayed-subscription", namespace: "late" }, | |
| }), | |
| ).toBeUndefined(); | |
| }); | |
| it("preserves run context until async terminal event subscriptions settle", async () => { | |
| let releaseTerminalHandler: (() => void) | undefined; | |
| let terminalHandlerSawContext: unknown; | |
| const { config, registry } = createPluginRegistryFixture(); | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "async-terminal-subscription", | |
| name: "Async Terminal Subscription", | |
| }), | |
| register(api) { | |
| api.registerAgentEventSubscription({ | |
| id: "records", | |
| streams: ["tool", "lifecycle"], | |
| async handle(event, ctx) { | |
| if (event.stream === "tool") { | |
| ctx.setRunContext("seen", { runId: event.runId }); | |
| return; | |
| } | |
| if (event.data?.phase !== "end") { | |
| return; | |
| } | |
| await new Promise<void>((resolve) => { | |
| releaseTerminalHandler = resolve; | |
| }); | |
| terminalHandlerSawContext = ctx.getRunContext("seen"); | |
| }, | |
| }); | |
| }, | |
| }); | |
| setActivePluginRegistry(registry.registry); | |
| emitAgentEvent({ | |
| runId: "run-async-terminal", | |
| stream: "tool", | |
| data: { name: "tool" }, | |
| }); | |
| await Promise.resolve(); | |
| emitAgentEvent({ | |
| runId: "run-async-terminal", | |
| stream: "lifecycle", | |
| data: { phase: "end" }, | |
| }); | |
| await Promise.resolve(); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "async-terminal-subscription", | |
| get: { runId: "run-async-terminal", namespace: "seen" }, | |
| }), | |
| ).toEqual({ runId: "run-async-terminal" }); | |
| releaseTerminalHandler?.(); | |
| await waitForPluginEventHandlers(); | |
| expect(terminalHandlerSawContext).toEqual({ runId: "run-async-terminal" }); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "async-terminal-subscription", | |
| get: { runId: "run-async-terminal", namespace: "seen" }, | |
| }), | |
| ).toBeUndefined(); | |
| }); | |
| it("waits for terminal handlers added after the first terminal cleanup waiter starts", async () => { | |
| let releaseFirstTerminalHandler: (() => void) | undefined; | |
| let releaseSecondTerminalHandler: (() => void) | undefined; | |
| let firstTerminalHandlerSawContext: unknown; | |
| let secondTerminalHandlerSawContext: unknown; | |
| let terminalEventsSeen = 0; | |
| const { config, registry } = createPluginRegistryFixture(); | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "repeated-terminal-live-wait", | |
| name: "Repeated Terminal Live Wait", | |
| }), | |
| register(api) { | |
| api.registerAgentEventSubscription({ | |
| id: "records", | |
| streams: ["tool", "lifecycle"], | |
| async handle(event, ctx) { | |
| if (event.stream === "tool") { | |
| ctx.setRunContext("seen", { runId: event.runId }); | |
| return; | |
| } | |
| if (event.data?.phase !== "end") { | |
| return; | |
| } | |
| terminalEventsSeen += 1; | |
| if (terminalEventsSeen === 1) { | |
| await new Promise<void>((resolve) => { | |
| releaseFirstTerminalHandler = resolve; | |
| }); | |
| firstTerminalHandlerSawContext = ctx.getRunContext("seen"); | |
| return; | |
| } | |
| await new Promise<void>((resolve) => { | |
| releaseSecondTerminalHandler = resolve; | |
| }); | |
| secondTerminalHandlerSawContext = ctx.getRunContext("seen"); | |
| }, | |
| }); | |
| }, | |
| }); | |
| setActivePluginRegistry(registry.registry); | |
| emitAgentEvent({ | |
| runId: "run-repeated-terminal-live-wait", | |
| stream: "tool", | |
| data: { name: "tool" }, | |
| }); | |
| await waitForPluginEventHandlers(); | |
| emitAgentEvent({ | |
| runId: "run-repeated-terminal-live-wait", | |
| stream: "lifecycle", | |
| data: { phase: "end" }, | |
| }); | |
| await waitForPluginEventHandlers(); | |
| emitAgentEvent({ | |
| runId: "run-repeated-terminal-live-wait", | |
| stream: "lifecycle", | |
| data: { phase: "end" }, | |
| }); | |
| await waitForPluginEventHandlers(); | |
| releaseFirstTerminalHandler?.(); | |
| await waitForPluginEventHandlers(); | |
| expect(firstTerminalHandlerSawContext).toEqual({ runId: "run-repeated-terminal-live-wait" }); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "repeated-terminal-live-wait", | |
| get: { runId: "run-repeated-terminal-live-wait", namespace: "seen" }, | |
| }), | |
| ).toEqual({ runId: "run-repeated-terminal-live-wait" }); | |
| releaseSecondTerminalHandler?.(); | |
| await waitForPluginEventHandlers(); | |
| await waitForPluginEventHandlers(); | |
| expect(secondTerminalHandlerSawContext).toEqual({ runId: "run-repeated-terminal-live-wait" }); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "repeated-terminal-live-wait", | |
| get: { runId: "run-repeated-terminal-live-wait", namespace: "seen" }, | |
| }), | |
| ).toBeUndefined(); | |
| }); | |
| it("clears run context after the terminal subscription grace period", async () => { | |
| vi.useFakeTimers(); | |
| let releaseTerminalHandler: (() => void) | undefined; | |
| let terminalHandlerSawContext: unknown; | |
| let terminalHandlerWroteContext: unknown; | |
| const { config, registry } = createPluginRegistryFixture(); | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "slow-terminal-subscription", | |
| name: "Slow Terminal Subscription", | |
| }), | |
| register(api) { | |
| api.registerAgentEventSubscription({ | |
| id: "slow", | |
| streams: ["tool", "lifecycle"], | |
| async handle(event, ctx) { | |
| if (event.stream === "tool") { | |
| ctx.setRunContext("seen", { runId: event.runId }); | |
| return; | |
| } | |
| if (event.data?.phase === "end") { | |
| await new Promise<void>((resolve) => { | |
| releaseTerminalHandler = resolve; | |
| }); | |
| terminalHandlerSawContext = ctx.getRunContext("seen"); | |
| ctx.setRunContext("terminal", { completed: true }); | |
| terminalHandlerWroteContext = ctx.getRunContext("terminal"); | |
| } | |
| }, | |
| }); | |
| }, | |
| }); | |
| setActivePluginRegistry(registry.registry); | |
| emitAgentEvent({ | |
| runId: "run-slow-terminal", | |
| stream: "tool", | |
| data: { name: "tool" }, | |
| }); | |
| await Promise.resolve(); | |
| emitAgentEvent({ | |
| runId: "run-slow-terminal", | |
| stream: "lifecycle", | |
| data: { phase: "end" }, | |
| }); | |
| await Promise.resolve(); | |
| await vi.advanceTimersByTimeAsync(PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "slow-terminal-subscription", | |
| get: { runId: "run-slow-terminal", namespace: "seen" }, | |
| }), | |
| ).toBeUndefined(); | |
| releaseTerminalHandler?.(); | |
| await vi.advanceTimersByTimeAsync(0); | |
| expect(terminalHandlerSawContext).toBeUndefined(); | |
| expect(terminalHandlerWroteContext).toBeUndefined(); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "slow-terminal-subscription", | |
| get: { runId: "run-slow-terminal", namespace: "seen" }, | |
| }), | |
| ).toBeUndefined(); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "slow-terminal-subscription", | |
| get: { runId: "run-slow-terminal", namespace: "terminal" }, | |
| }), | |
| ).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<void>((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({ | |
| pluginId: "scheduler-plugin", | |
| pluginName: "Scheduler Plugin", | |
| job: { | |
| id: "job-preserved", | |
| sessionKey: "agent:main:main", | |
| kind: "session-turn", | |
| cleanup, | |
| }, | |
| }); | |
| expectNoCleanupFailures( | |
| await runPluginHostCleanup({ | |
| reason: "disable", | |
| pluginId: "scheduler-plugin", | |
| preserveSchedulerJobIds: new Set(["job-preserved"]), | |
| }), | |
| ); | |
| expect(cleanup).not.toHaveBeenCalled(); | |
| expect(listPluginSessionSchedulerJobs("scheduler-plugin")).toHaveLength(1); | |
| }); | |
| it("preserves plugin run context during restart cleanup", async () => { | |
| const registry = createEmptyPluginRegistry(); | |
| expect( | |
| setPluginRunContext({ | |
| pluginId: "restart-context-plugin", | |
| patch: { runId: "run-restart", namespace: "state", value: { keep: true } }, | |
| }), | |
| ).toBe(true); | |
| expectNoCleanupFailures( | |
| await runPluginHostCleanup({ | |
| registry, | |
| pluginId: "restart-context-plugin", | |
| reason: "restart", | |
| }), | |
| ); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "restart-context-plugin", | |
| get: { runId: "run-restart", namespace: "state" }, | |
| }), | |
| ).toEqual({ keep: true }); | |
| expectNoCleanupFailures( | |
| await runPluginHostCleanup({ | |
| registry, | |
| pluginId: "restart-context-plugin", | |
| reason: "disable", | |
| }), | |
| ); | |
| expect( | |
| getPluginRunContext({ | |
| pluginId: "restart-context-plugin", | |
| get: { runId: "run-restart", namespace: "state" }, | |
| }), | |
| ).toBeUndefined(); | |
| }); | |
| it("preserves durable plugin session state during plugin restart cleanup", async () => { | |
| const { config, registry } = createPluginRegistryFixture(); | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "restart-state-fixture", | |
| name: "Restart State Fixture", | |
| }), | |
| register(api) { | |
| api.registerSessionExtension({ | |
| namespace: "workflow", | |
| description: "restart state test", | |
| }); | |
| }, | |
| }); | |
| const openClawState = await createOpenClawTestState({ | |
| layout: "state-only", | |
| prefix: "openclaw-run-context-restart-state-", | |
| }); | |
| const stateDir = openClawState.stateDir; | |
| const storePath = path.join(stateDir, "sessions.json"); | |
| const tempConfig = { | |
| session: { store: storePath }, | |
| }; | |
| try { | |
| await withTempConfig({ | |
| cfg: tempConfig, | |
| run: async () => { | |
| await updateSessionStore(storePath, (store) => { | |
| store["agent:main:main"] = { | |
| sessionId: "session-1", | |
| updatedAt: Date.now(), | |
| pluginExtensions: { | |
| "restart-state-fixture": { workflow: { state: "waiting" } }, | |
| }, | |
| pluginNextTurnInjections: { | |
| "restart-state-fixture": [ | |
| { | |
| id: "resume", | |
| pluginId: "restart-state-fixture", | |
| text: "resume", | |
| placement: "prepend_context", | |
| createdAt: 1, | |
| }, | |
| ], | |
| }, | |
| }; | |
| return undefined; | |
| }); | |
| expectNoCleanupFailures( | |
| await runPluginHostCleanup({ | |
| cfg: tempConfig, | |
| registry: registry.registry, | |
| pluginId: "restart-state-fixture", | |
| reason: "restart", | |
| }), | |
| ); | |
| const stored = loadSessionStore(storePath, { skipCache: true }); | |
| expect(stored["agent:main:main"]?.pluginExtensions).toEqual({ | |
| "restart-state-fixture": { workflow: { state: "waiting" } }, | |
| }); | |
| expect(stored["agent:main:main"]?.pluginNextTurnInjections).toEqual({ | |
| "restart-state-fixture": [ | |
| { | |
| id: "resume", | |
| pluginId: "restart-state-fixture", | |
| text: "resume", | |
| placement: "prepend_context", | |
| createdAt: 1, | |
| }, | |
| ], | |
| }); | |
| }, | |
| }); | |
| } finally { | |
| await openClawState.cleanup(); | |
| } | |
| }); | |
| it("rejects hung cleanup hooks with a bounded timeout", async () => { | |
| vi.useFakeTimers(); | |
| const cleanup = vi.fn(async () => { | |
| await new Promise(() => {}); | |
| }); | |
| registerPluginSessionSchedulerJob({ | |
| pluginId: "hung-cleanup-plugin", | |
| pluginName: "Hung Cleanup Plugin", | |
| job: { | |
| id: "job-hung", | |
| sessionKey: "agent:main:main", | |
| kind: "session-turn", | |
| cleanup, | |
| }, | |
| }); | |
| const resultPromise = runPluginHostCleanup({ | |
| reason: "disable", | |
| pluginId: "hung-cleanup-plugin", | |
| }); | |
| await vi.advanceTimersByTimeAsync(0); | |
| expect(cleanup).toHaveBeenCalledTimes(1); | |
| await vi.advanceTimersByTimeAsync(PLUGIN_HOST_CLEANUP_TIMEOUT_MS); | |
| const result = await resultPromise; | |
| expect(result.failures).toHaveLength(1); | |
| expect(result.failures[0]?.pluginId).toBe("hung-cleanup-plugin"); | |
| expect(result.failures[0]?.hookId).toBe("scheduler:job-hung"); | |
| }); | |
| it("bounds session, runtime, and scheduler cleanup callbacks so cleanup keeps moving", async () => { | |
| vi.useFakeTimers(); | |
| const { config, registry } = createPluginRegistryFixture(); | |
| const cleanupRelease = createDeferredCore(); | |
| registerTestPlugin({ | |
| registry, | |
| config, | |
| record: createPluginRecord({ | |
| id: "hanging-cleanup-fixture", | |
| name: "Hanging Cleanup Fixture", | |
| }), | |
| register(api) { | |
| api.registerSessionExtension({ | |
| namespace: "state", | |
| description: "hangs during cleanup", | |
| cleanup: () => cleanupRelease.promise, | |
| }); | |
| api.registerRuntimeLifecycle({ | |
| id: "runtime-cleanup", | |
| cleanup: () => cleanupRelease.promise, | |
| }); | |
| api.registerSessionSchedulerJob({ | |
| id: "scheduler-cleanup", | |
| sessionKey: "agent:main:main", | |
| kind: "monitor", | |
| cleanup: () => cleanupRelease.promise, | |
| }); | |
| }, | |
| }); | |
| setActivePluginRegistry(registry.registry); | |
| expect(listPluginSessionSchedulerJobs("hanging-cleanup-fixture")).toHaveLength(1); | |
| const cleanupPromise = runPluginHostCleanup({ | |
| cfg: config, | |
| registry: registry.registry, | |
| pluginId: "hanging-cleanup-fixture", | |
| reason: "delete", | |
| }); | |
| try { | |
| for (let index = 0; index < 3; index += 1) { | |
| await vi.advanceTimersByTimeAsync(PLUGIN_HOST_CLEANUP_TIMEOUT_MS + 1); | |
| } | |
| const result = await cleanupPromise; | |
| expect(result.failures).toHaveLength(3); | |
| for (const hookId of [ | |
| "session:state", | |
| "runtime:runtime-cleanup", | |
| "scheduler:scheduler-cleanup", | |
| ]) { | |
| const failure = requireFailureByHookId(result, hookId); | |
| expect(failure?.pluginId).toBe("hanging-cleanup-fixture"); | |
| } | |
| } finally { | |
| cleanupRelease.resolve(); | |
| await cleanupPromise; | |
| } | |
| }); | |
| it("blocks setting run context after a run is closed", () => { | |
| expect( | |
| setPluginRunContext({ | |
| pluginId: "closed-run-plugin", | |
| patch: { runId: "run-closed", namespace: "state", value: { before: true } }, | |
| }), | |
| ).toBe(true); | |
| dispatchPluginAgentEventSubscriptions({ | |
| registry: createEmptyPluginRegistry(), | |
| isLive: () => true, | |
| event: { | |
| runId: "run-closed", | |
| seq: 1, | |
| stream: "lifecycle", | |
| ts: Date.now(), | |
| data: { phase: "end" }, | |
| }, | |
| }); | |
| expect( | |
| setPluginRunContext({ | |
| pluginId: "closed-run-plugin", | |
| patch: { runId: "run-closed", namespace: "state", value: { after: true } }, | |
| }), | |
| ).toBe(false); | |
| }); | |
| }); | |