refactor(shutdown): coordinate worker drain handshake
This commit is contained in:
1 parent
9ee1425a71
commit
2ddc67cc3d
8 files changed
+286
-86
No files matched your search
@@ -9,7 +9,7 @@ import type { createOrchestrationService } from "../orchestration-service";
|
|||||||
import type { createWorkerService } from "../worker-service";
|
import type { createWorkerService } from "../worker-service";
|
||||||
import type { PiRuntimeServices } from "../service-graph-pi-runtime";
|
import type { PiRuntimeServices } from "../service-graph-pi-runtime";
|
||||||
|
|
||||||
/** Overall budget for awaited shutdown drain (worker exit + API close). */
|
/** Overall budget for awaited shutdown drain. */
|
||||||
const SHUTDOWN_BUDGET_MS = 5_000;
|
const SHUTDOWN_BUDGET_MS = 5_000;
|
||||||
|
|
||||||
export interface LifecycleDeps {
|
export interface LifecycleDeps {
|
||||||
@@ -38,10 +38,9 @@ export interface LifecycleDeps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the app lifecycle service and wire a deterministic shutdown drain
|
* Build the app lifecycle service and wire a deterministic shutdown drain
|
||||||
* that awaits the utility-process worker's exit (and API server close)
|
* that awaits the utility-process worker's ready-to-exit handshake
|
||||||
* before calling app.exit(). On 5s timeout we log the stuck run ids and
|
* before calling app.exit(). On timeout we log the stuck run ids and
|
||||||
* exit(1) so operators see an unclean shutdown rather than a silent
|
* exit(1) so operators see an unclean shutdown.
|
||||||
* force-kill.
|
|
||||||
*/
|
*/
|
||||||
export function createLifecycle(deps: LifecycleDeps) {
|
export function createLifecycle(deps: LifecycleDeps) {
|
||||||
const {
|
const {
|
||||||
@@ -72,8 +71,6 @@ export function createLifecycle(deps: LifecycleDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function drain(): Promise<number> {
|
async function drain(): Promise<number> {
|
||||||
// Stop accepting new scheduled work first; cancel in-flight runs in the
|
|
||||||
// worker via the utility-process shutdown postMessage inside destroy().
|
|
||||||
try { orchestrationService.stopAllSchedulers(); } catch (err) { logger.warn("[lifecycle] stopAllSchedulers failed:", err); }
|
try { orchestrationService.stopAllSchedulers(); } catch (err) { logger.warn("[lifecycle] stopAllSchedulers failed:", err); }
|
||||||
try { automationService.destroy(); } catch (err) { logger.warn("[lifecycle] automationService.destroy failed:", err); }
|
try { automationService.destroy(); } catch (err) { logger.warn("[lifecycle] automationService.destroy failed:", err); }
|
||||||
try { apiServerRef.current?.stop(); } catch (err) { logger.warn("[lifecycle] apiServer.stop failed:", err); }
|
try { apiServerRef.current?.stop(); } catch (err) { logger.warn("[lifecycle] apiServer.stop failed:", err); }
|
||||||
@@ -81,22 +78,12 @@ export function createLifecycle(deps: LifecycleDeps) {
|
|||||||
try { browserManagerRef.current?.shutdown(); } catch (err) { logger.warn("[lifecycle] browserManager.shutdown failed:", err); }
|
try { browserManagerRef.current?.shutdown(); } catch (err) { logger.warn("[lifecycle] browserManager.shutdown failed:", err); }
|
||||||
browserManagerRef.current = null;
|
browserManagerRef.current = null;
|
||||||
|
|
||||||
// workerService.destroy() sends "shutdown" to the utility process and has
|
|
||||||
// its own 2s SIGKILL safety net. Awaiting the worker's exit event closes
|
|
||||||
// the remaining event-loop holder (the utilityProcess handle).
|
|
||||||
const workerExit = new Promise<void>((resolve) => {
|
|
||||||
const worker = workerService.getWorker();
|
|
||||||
if (!worker) { resolve(); return; }
|
|
||||||
worker.once("exit", () => resolve());
|
|
||||||
});
|
|
||||||
workerService.destroy();
|
|
||||||
|
|
||||||
const timeout = new Promise<"timeout">((resolve) => {
|
const timeout = new Promise<"timeout">((resolve) => {
|
||||||
const handle = setTimeout(() => resolve("timeout"), SHUTDOWN_BUDGET_MS);
|
const handle = setTimeout(() => resolve("timeout"), SHUTDOWN_BUDGET_MS);
|
||||||
if (typeof handle.unref === "function") handle.unref();
|
if (typeof handle.unref === "function") handle.unref();
|
||||||
});
|
});
|
||||||
const outcome = await Promise.race([
|
const outcome = await Promise.race([
|
||||||
workerExit.then(() => "drained" as const),
|
workerService.shutdown().then(() => "drained" as const),
|
||||||
timeout,
|
timeout,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+104
-58
@@ -32,6 +32,7 @@ export function createWorkerService({
|
|||||||
|
|
||||||
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
let shutdownPromise: Promise<void> | null = null;
|
||||||
const pendingRetryTimers = new Set<ReturnType<typeof setTimeout>>();
|
const pendingRetryTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
/** Crash-loop backoff state. Resets to 0 whenever a worker lives long enough
|
/** Crash-loop backoff state. Resets to 0 whenever a worker lives long enough
|
||||||
@@ -110,6 +111,16 @@ export function createWorkerService({
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.on("message", async (message) => {
|
worker.on("message", async (message) => {
|
||||||
|
if (isShutdownReadyMessage(message)) {
|
||||||
|
try {
|
||||||
|
worker?.postMessage({ type: "shutdown-exit" });
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(
|
||||||
|
`[worker-service] shutdown-exit postMessage failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
await handleWorkerMessage(
|
await handleWorkerMessage(
|
||||||
message,
|
message,
|
||||||
worker,
|
worker,
|
||||||
@@ -140,66 +151,58 @@ export function createWorkerService({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function collectShutdownRuns(): Array<{
|
||||||
destroyed = true;
|
id: string;
|
||||||
|
taskId: string | null;
|
||||||
// Clear all pending retry timers so they never fire after quit
|
workspaceId: string | null;
|
||||||
for (const timer of pendingRetryTimers) {
|
}> {
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
pendingRetryTimers.clear();
|
|
||||||
|
|
||||||
if (worker) {
|
|
||||||
// Tell the worker to kill all its child CLI processes and exit.
|
|
||||||
// Previous approach sent individual cancel-run messages then immediately
|
|
||||||
// killed the worker — a race condition that left CLI processes as orphans
|
|
||||||
// because the worker died before it could forward SIGTERM to its children.
|
|
||||||
try {
|
|
||||||
worker.postMessage({ type: "shutdown" });
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(
|
|
||||||
`[worker-service] shutdown postMessage failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safety net: force-kill worker after 2s if shutdown didn't complete.
|
|
||||||
// Track the timer so we can cancel it the moment the worker cleanly
|
|
||||||
// exits — otherwise the pending timer keeps the event loop alive.
|
|
||||||
const ref = worker;
|
|
||||||
const killTimer = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
ref.kill();
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(
|
|
||||||
`[worker-service] worker kill() failed (likely already dead): ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
ref.once("exit", () => clearTimeout(killTimer));
|
|
||||||
worker = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark all running/queued runs as interrupted so DB state is clean on restart
|
|
||||||
try {
|
try {
|
||||||
const snapshot = db.projection.snapshot.listSnapshot();
|
const snapshot = db.projection.snapshot.listSnapshot();
|
||||||
for (const run of snapshot.runs) {
|
return snapshot.runs
|
||||||
if (run.status === "running" || run.status === "queued") {
|
.filter((run) => run.status === "running" || run.status === "queued")
|
||||||
db.core.runs.finishRun({
|
.map((run) => ({ id: run.id, taskId: run.taskId, workspaceId: run.workspaceId }));
|
||||||
runId: run.id,
|
} catch (error) {
|
||||||
status: "interrupted",
|
logger.warn(
|
||||||
summary: "Application shut down.",
|
`[worker-service] shutdown run collection failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
errorMessage: null,
|
);
|
||||||
exitCode: null,
|
return [];
|
||||||
signal: null,
|
}
|
||||||
model: null,
|
}
|
||||||
unattributedCost: true,
|
|
||||||
});
|
function interruptShutdownRuns(runs: Array<{
|
||||||
if (run.workspaceId) {
|
id: string;
|
||||||
db.core.workspaces.releaseWorkspaceLock(run.workspaceId, run.id);
|
taskId: string | null;
|
||||||
}
|
workspaceId: string | null;
|
||||||
if (run.taskId) {
|
}>): void {
|
||||||
db.core.tasks.releaseTaskLock(run.taskId, run.id);
|
try {
|
||||||
}
|
for (const run of runs) {
|
||||||
|
let status: string;
|
||||||
|
try {
|
||||||
|
status = db.core.runs.getRun(run.id).status;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[worker-service] shutdown cleanup skipped missing run ${run.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status !== "running" && status !== "queued") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
db.core.runs.finishRun({
|
||||||
|
runId: run.id,
|
||||||
|
status: "interrupted",
|
||||||
|
summary: "Application shut down.",
|
||||||
|
errorMessage: null,
|
||||||
|
exitCode: null,
|
||||||
|
signal: null,
|
||||||
|
model: null,
|
||||||
|
unattributedCost: true,
|
||||||
|
});
|
||||||
|
if (run.workspaceId) {
|
||||||
|
db.core.workspaces.releaseWorkspaceLock(run.workspaceId, run.id);
|
||||||
|
}
|
||||||
|
if (run.taskId) {
|
||||||
|
db.core.tasks.releaseTaskLock(run.taskId, run.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -209,9 +212,52 @@ export function createWorkerService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shutdown(): Promise<void> {
|
||||||
|
if (shutdownPromise) return shutdownPromise;
|
||||||
|
destroyed = true;
|
||||||
|
const shutdownRuns = collectShutdownRuns();
|
||||||
|
|
||||||
|
for (const timer of pendingRetryTimers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
pendingRetryTimers.clear();
|
||||||
|
|
||||||
|
if (!worker) {
|
||||||
|
interruptShutdownRuns(shutdownRuns);
|
||||||
|
shutdownPromise = Promise.resolve();
|
||||||
|
return shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = worker;
|
||||||
|
shutdownPromise = new Promise<void>((resolve) => {
|
||||||
|
ref.once("exit", () => {
|
||||||
|
if (worker === ref) worker = null;
|
||||||
|
interruptShutdownRuns(shutdownRuns);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
ref.postMessage({ type: "shutdown" });
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(
|
||||||
|
`[worker-service] shutdown postMessage failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
void shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createWorker,
|
createWorker,
|
||||||
getWorker,
|
getWorker,
|
||||||
|
shutdown,
|
||||||
destroy,
|
destroy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isShutdownReadyMessage(message: unknown): message is { type: "shutdown-ready" } {
|
||||||
|
return Boolean(message && typeof message === "object" && (message as { type?: unknown }).type === "shutdown-ready");
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ interface ActiveRun {
|
|||||||
const active = new Map<string, ActiveRun>();
|
const active = new Map<string, ActiveRun>();
|
||||||
const queue: RunJob[] = [];
|
const queue: RunJob[] = [];
|
||||||
const cancelledRuns = new Set<string>();
|
const cancelledRuns = new Set<string>();
|
||||||
|
let shuttingDown = false;
|
||||||
|
let drainAckResolver: (() => void) | null = null;
|
||||||
|
|
||||||
function post(message: ChildMessage) {
|
function post(message: ChildMessage) {
|
||||||
parentPort.postMessage(message);
|
parentPort.postMessage(message);
|
||||||
@@ -57,6 +59,7 @@ function isWorkspaceBusy(lockKey: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enqueue(job: RunJob) {
|
function enqueue(job: RunJob) {
|
||||||
|
if (shuttingDown) return;
|
||||||
queue.push(job);
|
queue.push(job);
|
||||||
post({
|
post({
|
||||||
type: "run-status",
|
type: "run-status",
|
||||||
@@ -73,6 +76,7 @@ function enqueue(job: RunJob) {
|
|||||||
* `startRun`. No async window between claim and dispatch.
|
* `startRun`. No async window between claim and dispatch.
|
||||||
*/
|
*/
|
||||||
function tickQueue() {
|
function tickQueue() {
|
||||||
|
if (shuttingDown) return;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < queue.length) {
|
while (i < queue.length) {
|
||||||
const job = queue[i];
|
const job = queue[i];
|
||||||
@@ -96,6 +100,17 @@ async function startRun(job: RunJob) {
|
|||||||
function acknowledgeRunCompletion(runId: string) {
|
function acknowledgeRunCompletion(runId: string) {
|
||||||
const released = active.delete(runId);
|
const released = active.delete(runId);
|
||||||
if (released) tickQueue();
|
if (released) tickQueue();
|
||||||
|
if (shuttingDown && active.size === 0 && drainAckResolver) {
|
||||||
|
drainAckResolver();
|
||||||
|
drainAckResolver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForCompletionAcks(): Promise<void> {
|
||||||
|
if (active.size === 0) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
drainAckResolver = resolve;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelRun(runId: string) {
|
function cancelRun(runId: string) {
|
||||||
@@ -151,17 +166,18 @@ parentPort.on("message", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "shutdown") {
|
if (message.type === "shutdown") {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
// Graceful drain: abort every active run and await its promise
|
queue.splice(0);
|
||||||
// settlement so that final `run-finished` messages and
|
await runner.cancelAll();
|
||||||
// `persistMessage` RPCs reach the parent before we exit. Bounded by
|
await waitForCompletionAcks();
|
||||||
// a 5s budget so a stuck run does not wedge the shutdown handshake
|
|
||||||
// (the parent has its own kill-timer as a safety net).
|
|
||||||
const drainBudgetMs = 5_000;
|
|
||||||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, drainBudgetMs));
|
|
||||||
await Promise.race([runner.cancelAll(), timeout]);
|
|
||||||
rpc.cancelAll("Worker shutting down.");
|
rpc.cancelAll("Worker shutting down.");
|
||||||
process.exit(0);
|
post({ type: "shutdown-ready" });
|
||||||
})();
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === "shutdown-exit") {
|
||||||
|
process.exit(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -118,5 +118,7 @@ export async function handleWorkerMessage(
|
|||||||
deps.getRuntimeMessageInjector?.()?.handleSteerAck(event.correlationId, event.accepted);
|
deps.getRuntimeMessageInjector?.()?.handleSteerAck(event.correlationId, event.accepted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "shutdown-ready":
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,9 @@ export const workerMessageSchema = z.union([
|
|||||||
correlationId: z.string(),
|
correlationId: z.string(),
|
||||||
accepted: z.boolean(),
|
accepted: z.boolean(),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("shutdown-ready"),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface WorkerServiceDependencies {
|
export interface WorkerServiceDependencies {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type ParentMessage =
|
|||||||
| { type: "cancel-run"; runId: string }
|
| { type: "cancel-run"; runId: string }
|
||||||
| { type: "run-completion-ack"; runId: string }
|
| { type: "run-completion-ack"; runId: string }
|
||||||
| { type: "shutdown" }
|
| { type: "shutdown" }
|
||||||
|
| { type: "shutdown-exit" }
|
||||||
| { type: "steer-run"; runId: string; correlationId: string; message: AgentMessage }
|
| { type: "steer-run"; runId: string; correlationId: string; message: AgentMessage }
|
||||||
| { type: "rpc-response"; rpcId: string; response: { ok: true; result: unknown } | { ok: false; error: string } };
|
| { type: "rpc-response"; rpcId: string; response: { ok: true; result: unknown } | { ok: false; error: string } };
|
||||||
|
|
||||||
@@ -89,7 +90,8 @@ export type ChildMessage =
|
|||||||
}
|
}
|
||||||
| { type: "rpc-request"; rpcId: string; runId: string; method: string; payload: unknown }
|
| { type: "rpc-request"; rpcId: string; runId: string; method: string; payload: unknown }
|
||||||
| { type: "run-agent-state"; runId: string; state: RunAgentLifecycleState }
|
| { type: "run-agent-state"; runId: string; state: RunAgentLifecycleState }
|
||||||
| { type: "steer-ack"; runId: string; correlationId: string; accepted: boolean };
|
| { type: "steer-ack"; runId: string; correlationId: string; accepted: boolean }
|
||||||
|
| { type: "shutdown-ready" };
|
||||||
|
|
||||||
export function buildRedactor(env: Record<string, string>): (text: string) => string {
|
export function buildRedactor(env: Record<string, string>): (text: string) => string {
|
||||||
const sensitivePatterns: string[] = [];
|
const sensitivePatterns: string[] = [];
|
||||||
|
|||||||
@@ -285,4 +285,62 @@ describe("worker completion boundary", () => {
|
|||||||
expect.objectContaining({ type: "run-finished", runId: NEXT_RUN_ID }),
|
expect.objectContaining({ type: "run-finished", runId: NEXT_RUN_ID }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports shutdown-ready only after parent acknowledges run completion", async () => {
|
||||||
|
const parentPort = new EventEmitter() as EventEmitter & {
|
||||||
|
postMessage: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
parentPort.postMessage = vi.fn();
|
||||||
|
Object.defineProperty(process, "parentPort", {
|
||||||
|
configurable: true,
|
||||||
|
value: parentPort,
|
||||||
|
});
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||||
|
|
||||||
|
vi.doMock("@main/worker/runtime", () => ({
|
||||||
|
WorkerRunner: class TestWorkerRunner {
|
||||||
|
constructor(
|
||||||
|
private readonly deps: {
|
||||||
|
post: (message: unknown) => void;
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run(job: RunJob): Promise<void> {
|
||||||
|
this.deps.post(runFinishedMessage(job.runId));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {}
|
||||||
|
|
||||||
|
steer(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelAll(): Promise<void> {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await import("@main/worker/entry/index");
|
||||||
|
|
||||||
|
parentPort.emit("message", { data: { type: "enqueue-run", payload: runJob(RUN_ID) } });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
parentPort.emit("message", { data: { type: "shutdown" } });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(parentPort.postMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ type: "run-finished", runId: RUN_ID }),
|
||||||
|
);
|
||||||
|
expect(parentPort.postMessage).not.toHaveBeenCalledWith({ type: "shutdown-ready" });
|
||||||
|
|
||||||
|
parentPort.emit("message", { data: { type: "run-completion-ack", runId: RUN_ID } });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(parentPort.postMessage).toHaveBeenCalledWith({ type: "shutdown-ready" });
|
||||||
|
|
||||||
|
parentPort.emit("message", { data: { type: "shutdown-exit" } });
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
forkMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
app: { isPackaged: false },
|
||||||
|
utilityProcess: { fork: mocks.forkMock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createWorkerService } from "../../src/main/worker-service";
|
||||||
|
|
||||||
|
function createWorker() {
|
||||||
|
const worker = new EventEmitter() as EventEmitter & {
|
||||||
|
stdout: EventEmitter;
|
||||||
|
stderr: EventEmitter;
|
||||||
|
postMessage: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
worker.stdout = new EventEmitter();
|
||||||
|
worker.stderr = new EventEmitter();
|
||||||
|
worker.postMessage = vi.fn();
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("worker service shutdown", () => {
|
||||||
|
it("keeps completed run outcomes when cleaning shutdown runs", async () => {
|
||||||
|
const worker = createWorker();
|
||||||
|
mocks.forkMock.mockReturnValueOnce(worker);
|
||||||
|
const finishRun = vi.fn();
|
||||||
|
const db = {
|
||||||
|
projection: {
|
||||||
|
snapshot: {
|
||||||
|
listSnapshot: vi.fn(() => ({
|
||||||
|
runs: [
|
||||||
|
{ id: "run-succeeded", status: "running", taskId: null, workspaceId: null },
|
||||||
|
{ id: "run-queued", status: "queued", taskId: null, workspaceId: null },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
runs: {
|
||||||
|
getRun: vi.fn((id: string) => ({
|
||||||
|
id,
|
||||||
|
status: id === "run-succeeded" ? "succeeded" : "queued",
|
||||||
|
})),
|
||||||
|
finishRun,
|
||||||
|
},
|
||||||
|
workspaces: { releaseWorkspaceLock: vi.fn() },
|
||||||
|
tasks: { releaseTaskLock: vi.fn() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = createWorkerService({
|
||||||
|
db,
|
||||||
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
workerPath: "/tmp/worker.js",
|
||||||
|
isQuitting: () => false,
|
||||||
|
publishDomainChanged: vi.fn(),
|
||||||
|
emitEvent: vi.fn(),
|
||||||
|
wakeAgentIfPossible: vi.fn(() => null),
|
||||||
|
dispatchRunToWorker: vi.fn(),
|
||||||
|
processRunCompletion: vi.fn(),
|
||||||
|
onRunStarted: vi.fn(),
|
||||||
|
onRunCompleted: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
budgetService: undefined,
|
||||||
|
sessionService: undefined,
|
||||||
|
getRuntimeBridge: vi.fn(() => null),
|
||||||
|
getRuntimeMessageInjector: vi.fn(() => null),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
service.createWorker();
|
||||||
|
const shutdown = service.shutdown();
|
||||||
|
worker.emit("exit", 0);
|
||||||
|
await shutdown;
|
||||||
|
|
||||||
|
expect(worker.postMessage).toHaveBeenCalledWith({ type: "shutdown" });
|
||||||
|
expect(finishRun).toHaveBeenCalledTimes(1);
|
||||||
|
expect(finishRun).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
runId: "run-queued",
|
||||||
|
status: "interrupted",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user