From ba1673e668597a46cf922549f2ecaab72dc2ce44 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 13 Jun 2026 12:25:47 +0200 Subject: [PATCH] Force factory daemon exit after graceful stop --- packages/factory-sdk/src/cli/fleet.test.ts | 80 +++++++++++++++++++++- packages/factory-sdk/src/cli/fleet.ts | 30 +++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/factory-sdk/src/cli/fleet.test.ts b/packages/factory-sdk/src/cli/fleet.test.ts index 366d550c..a0bce4e1 100644 --- a/packages/factory-sdk/src/cli/fleet.test.ts +++ b/packages/factory-sdk/src/cli/fleet.test.ts @@ -444,6 +444,7 @@ describe('fleet CLI runtime', () => { try { const configPath = await writeConfig(root) const listeners = new Map void>() + const calls: string[] = [] const processLike = { once(signal: string, listener: () => void) { listeners.set(signal, listener) @@ -456,7 +457,9 @@ describe('fleet CLI runtime', () => { } const factory = { start: vi.fn(async () => {}), - stop: vi.fn(async () => {}), + stop: vi.fn(async () => { + calls.push('stop') + }), runLoop: vi.fn(async () => []), runOnce: vi.fn(), status: vi.fn(), @@ -466,6 +469,7 @@ describe('fleet CLI runtime', () => { dispose: vi.fn(), } as unknown as Factory const createFactory = vi.fn(() => factory) + const daemonExits: number[] = [] const run = runFleetCli([ 'factory', @@ -479,6 +483,13 @@ describe('fleet CLI runtime', () => { mount: new FakeMountClient(), createFactory, stopSignalProcessLike: processLike as unknown as Pick, + flushDaemonOutput: async () => { + calls.push('flush') + }, + daemonExit: (code) => { + calls.push('exit') + daemonExits.push(code) + }, stdout: buffer(), stderr: buffer(), }) @@ -489,12 +500,79 @@ describe('fleet CLI runtime', () => { await expect(run).resolves.toBe(0) expect(factory.stop).toHaveBeenCalledTimes(1) + expect(calls).toEqual(['stop', 'flush', 'exit']) + expect(daemonExits).toEqual([0]) expect(listeners.size).toBe(0) } finally { await rm(root, { recursive: true, force: true }) } }) + it('does not force process exit for one-shot factory commands', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-one-shot-no-force-exit-')) + try { + const configPath = await writeConfig(root) + const daemonExits: number[] = [] + const daemonFlushes: string[] = [] + const runOnceFactory = { + start: vi.fn(), + stop: vi.fn(), + runLoop: vi.fn(async () => []), + runOnce: vi.fn(async () => ({ pulled: [], triaged: [], dispatched: [], skipped: [], dryRun: true })), + status: vi.fn(), + triageIssue: vi.fn(), + dispatch: vi.fn(), + on: vi.fn(), + dispose: vi.fn(), + } as unknown as Factory + + const runOnceCode = await runFleetCli([ + '--dry-run', + 'factory', + 'run-once', + '--config', + configPath, + ], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient(), + createFactory: () => runOnceFactory, + daemonExit: (code) => { + daemonExits.push(code) + }, + flushDaemonOutput: async () => { + daemonFlushes.push('flush') + }, + stdout: buffer(), + stderr: buffer(), + }) + + const reapCode = await runFleetCli([ + 'factory', + 'reap-orphans', + '--config', + configPath, + ], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient(), + daemonExit: (code) => { + daemonExits.push(code) + }, + flushDaemonOutput: async () => { + daemonFlushes.push('flush') + }, + stdout: buffer(), + stderr: buffer(), + }) + + expect(runOnceCode).toBe(0) + expect(reapCode).toBe(0) + expect(daemonExits).toEqual([]) + expect(daemonFlushes).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('factory kill-loop sends SIGTERM to the heartbeat pid', async () => { const root = await mkdtemp(join(tmpdir(), 'fleet-cli-kill-')) const originalKill = process.kill diff --git a/packages/factory-sdk/src/cli/fleet.ts b/packages/factory-sdk/src/cli/fleet.ts index c8509651..78d00c2d 100644 --- a/packages/factory-sdk/src/cli/fleet.ts +++ b/packages/factory-sdk/src/cli/fleet.ts @@ -36,6 +36,8 @@ interface FleetCliDeps { probeCloser?: ProbeCloser now?: () => number stopSignalProcessLike?: Pick + daemonExit?: (code: number) => void + flushDaemonOutput?: () => Promise } interface GlobalOptions { @@ -215,10 +217,19 @@ async function runFactoryCommand( if (command.action === 'start') { const waiter = createStopSignalWaiter() let stoppedBySignal = false + const flushAndExit = async (code: number): Promise => { + try { + await (deps.flushDaemonOutput ?? flushProcessOutput)() + } finally { + const daemonExit = deps.daemonExit ?? ((exitCode: number) => process.exit(exitCode)) + daemonExit(code) + waiter.resolve(code) + } + } const removeSignalHandlers = installFactoryStopSignalHandlers(factory, { exit: (code) => { stoppedBySignal = true - waiter.resolve(code) + void flushAndExit(code) }, processLike: deps.stopSignalProcessLike, }) @@ -467,6 +478,23 @@ function writeJson(out: Pick, value: unknown): void out.write(`${JSON.stringify(value, null, 2)}\n`) } +async function flushProcessOutput(): Promise { + await Promise.all([ + flushWritable(process.stdout), + flushWritable(process.stderr), + ]) +} + +function flushWritable(stream: NodeJS.WriteStream): Promise { + if (stream.destroyed || stream.writableEnded || stream.writable === false) { + return Promise.resolve() + } + + return new Promise((resolve) => { + stream.write('', () => resolve()) + }) +} + function isCapability(value: string | undefined): value is Capability { return value === 'spawn:codex' || value === 'spawn:claude' }