From 37b3d5b623caf7d8cd26720eb9ba44c0dc1811ab Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 15 Aug 2026 11:40:10 +0900 Subject: [PATCH] fix(lab): cancel manual runs during shutdown --- src/lab/automation/orchestrator.ts | 16 +++++-- .../lab-automation-review-regressions.test.ts | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/lab/automation/orchestrator.ts b/src/lab/automation/orchestrator.ts index aed122020..b13cd97d1 100644 --- a/src/lab/automation/orchestrator.ts +++ b/src/lab/automation/orchestrator.ts @@ -473,8 +473,18 @@ export async function enqueueManualLabRun( return { state: next, value: run }; }); if (!created) return null; - // Manual execution is independent of automation enablement/layer toggles. - await runDispatchBatch(configDir, { manualRunId: created.runId, abortSignal }); + // Manual execution can run without activation or a scheduler, so it must own a shutdown + // hook for the lifetime of its dispatch instead of relying on either of those paths. + const detachShutdownHook = registerOptionalShutdownHook( + `lab-automation-manual:${created.runId}`, + requestLabAutomationShutdown, + ); + try { + // Manual execution is independent of automation enablement/layer toggles. + await runDispatchBatch(configDir, { manualRunId: created.runId, abortSignal }); + } finally { + detachShutdownHook(); + } return loadLabAutomationState(configDir).runs.find((row) => row.runId === created.runId) ?? created; } @@ -496,4 +506,4 @@ export function cancelLabAutomationRun(runId: string, configDir?: string): boole } return { state: next, value: true }; }); -} \ No newline at end of file +} diff --git a/tests/lab-automation-review-regressions.test.ts b/tests/lab-automation-review-regressions.test.ts index 52075bff9..c40778bc0 100644 --- a/tests/lab-automation-review-regressions.test.ts +++ b/tests/lab-automation-review-regressions.test.ts @@ -31,6 +31,10 @@ import type { import { LabAutomationError } from "../src/lab/automation/types"; import { LAB_AUTOMATION_HARD_MAX } from "../src/lab/automation/constants"; import { createHostIssuedLabRouteExecutor } from "../src/lib/lab-live-host"; +import { + resetOptionalShutdownHooksForTests, + runOptionalShutdownHooks, +} from "../src/lib/optional-shutdown-hooks"; import { readInstallationSalt } from "../src/lab/subject/installation-salt"; import type { NormalizedObservation } from "../src/lab/conformance/types"; import { @@ -149,6 +153,7 @@ afterEach(() => { requestLabAutomationShutdown(); stopLabAutomationScheduler(); resetLabAutomationSchedulerStateForTests(); + resetOptionalShutdownHooksForTests(); setLabAutomationDispatchDeps({}); resetCompatibilityVersionCacheForTests(); delete process.env.OPENCODEX_HOME; @@ -239,6 +244,43 @@ describe("CL-08 independent review regressions", () => { expect(result?.state).not.toBe("running"); }); + test("manual run remains cancellable by shutdown after its activation lease is released", async () => { + const home = tempHome(); + prepareHome(home); + const config = providerConfig(); + const plan = planManualLabRun({ + evidenceLayer: "live_route_compatibility", + scenarioId: "responses-core.live.basic-turn", + providerName: "fixture-provider", + modelId: "fixture-model", + config, + configDir: home, + }); + let startedResolve!: () => void; + const started = new Promise((resolve) => { startedResolve = resolve; }); + const release = setLabAutomationDispatchDeps({ + configDir: home, + loadConfig: () => config, + resolve: fixtureDnsResolve(), + routeExecutor: createHostIssuedLabRouteExecutor(async (input) => { + startedResolve(); + if (!input.signal.aborted) { + await new Promise((resolve) => input.signal.addEventListener("abort", () => resolve(), { once: true })); + } + return passObservation(); + }), + }); + + const manualRun = enqueueManualLabRun(plan, home); + await started; + release(); + runOptionalShutdownHooks(); + + const result = await manualRun; + expect(result?.state).toBe("cancelled"); + expect(result?.terminalCode).toBe("cancelled"); + }); + test("disabling the live layer cancels previously queued scheduled live work", async () => { const home = tempHome(); prepareHome(home);