From 31dd4353d48fa1b193c75f9a0d93e5f7c3360842 Mon Sep 17 00:00:00 2001 From: junes <673638712@qq.com> Date: Sun, 31 May 2026 19:34:58 +0800 Subject: [PATCH] fix(cli): replace existing runner on start (#754) --- cli/src/commands/runner.test.ts | 129 ++++++++++++++++++++++++++++++++ cli/src/commands/runner.ts | 24 +++++- 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 cli/src/commands/runner.test.ts diff --git a/cli/src/commands/runner.test.ts b/cli/src/commands/runner.test.ts new file mode 100644 index 00000000..434d7b3f --- /dev/null +++ b/cli/src/commands/runner.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + checkIfRunnerRunningAndCleanupStaleStateMock, + listRunnerSessionsMock, + stopRunnerMock, + stopRunnerSessionMock, + spawnHappyCLIMock, + startRunnerMock, + getLatestRunnerLogMock, + runDoctorCommandMock, + initializeTokenMock, + existsSyncMock, + statSyncMock +} = vi.hoisted(() => ({ + checkIfRunnerRunningAndCleanupStaleStateMock: vi.fn(), + listRunnerSessionsMock: vi.fn(async () => []), + stopRunnerMock: vi.fn(async () => {}), + stopRunnerSessionMock: vi.fn(async () => true), + spawnHappyCLIMock: vi.fn(() => ({ unref: vi.fn() })), + startRunnerMock: vi.fn(async () => {}), + getLatestRunnerLogMock: vi.fn(async () => null), + runDoctorCommandMock: vi.fn(async () => {}), + initializeTokenMock: vi.fn(async () => {}), + existsSyncMock: vi.fn(() => true), + statSyncMock: vi.fn(() => ({ isDirectory: () => true })) +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: existsSyncMock, + statSync: statSyncMock + } +}) + +vi.mock('@/runner/controlClient', () => ({ + checkIfRunnerRunningAndCleanupStaleState: checkIfRunnerRunningAndCleanupStaleStateMock, + listRunnerSessions: listRunnerSessionsMock, + stopRunner: stopRunnerMock, + stopRunnerSession: stopRunnerSessionMock +})) + +vi.mock('@/utils/spawnHappyCLI', () => ({ + spawnHappyCLI: spawnHappyCLIMock +})) + +vi.mock('@/runner/run', () => ({ + startRunner: startRunnerMock +})) + +vi.mock('@/ui/logger', () => ({ + getLatestRunnerLog: getLatestRunnerLogMock +})) + +vi.mock('@/ui/doctor', () => ({ + runDoctorCommand: runDoctorCommandMock +})) + +vi.mock('@/ui/tokenInit', () => ({ + initializeToken: initializeTokenMock +})) + +import { runnerCommand } from './runner' + +function createContext(commandArgs: string[]) { + return { + args: ['runner', ...commandArgs], + commandArgs + } +} + +describe('runnerCommand start', () => { + beforeEach(() => { + vi.clearAllMocks() + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue({ isDirectory: () => true }) + }) + + it('stops an existing runner before starting a new detached runner', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 'undefined'}`) + }) as never) + checkIfRunnerRunningAndCleanupStaleStateMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + + try { + await expect(runnerCommand.run(createContext(['start', '--workspace-root', '/workspace']))).rejects.toThrow('process.exit:0') + + expect(stopRunnerMock).toHaveBeenCalledOnce() + expect(spawnHappyCLIMock).toHaveBeenCalledWith(['runner', 'start-sync', '--workspace-root', '/workspace'], { + detached: true, + stdio: 'ignore', + env: process.env + }) + expect(stopRunnerMock.mock.invocationCallOrder[0]).toBeLessThan(spawnHappyCLIMock.mock.invocationCallOrder[0]) + expect(consoleLogSpy).toHaveBeenCalledWith('Existing runner detected, stopping it before starting a new one...') + expect(consoleLogSpy).toHaveBeenCalledWith('Runner started successfully') + } finally { + consoleLogSpy.mockRestore() + exitSpy.mockRestore() + } + }) + + it('starts directly when no runner is already running', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 'undefined'}`) + }) as never) + checkIfRunnerRunningAndCleanupStaleStateMock + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + + try { + await expect(runnerCommand.run(createContext(['start']))).rejects.toThrow('process.exit:0') + + expect(stopRunnerMock).not.toHaveBeenCalled() + expect(spawnHappyCLIMock).toHaveBeenCalledOnce() + expect(consoleLogSpy).toHaveBeenCalledWith('Runner started successfully') + } finally { + consoleLogSpy.mockRestore() + exitSpy.mockRestore() + } + }) +}) diff --git a/cli/src/commands/runner.ts b/cli/src/commands/runner.ts index e08c9cb1..b74419e9 100644 --- a/cli/src/commands/runner.ts +++ b/cli/src/commands/runner.ts @@ -70,6 +70,17 @@ function extractWorkspaceRootArgs(args: string[]): string[] | undefined { return uniqueWorkspaceRoots.length > 0 ? uniqueWorkspaceRoots : undefined } +async function waitForRunnerToStop(maxAttempts = 50): Promise { + for (let i = 0; i < maxAttempts; i++) { + if (!(await checkIfRunnerRunningAndCleanupStaleState())) { + return true + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + + return false +} + export const runnerCommand: CommandDefinition = { name: 'runner', requiresRuntimeAssets: true, @@ -111,6 +122,16 @@ export const runnerCommand: CommandDefinition = { } if (runnerSubcommand === 'start') { + if (await checkIfRunnerRunningAndCleanupStaleState()) { + console.log('Existing runner detected, stopping it before starting a new one...') + await stopRunner() + + if (!(await waitForRunnerToStop())) { + console.error('Failed to stop existing runner') + process.exit(1) + } + } + const childArgs = ['runner', 'start-sync'] if (workspaceRoots?.length) { for (const workspaceRoot of workspaceRoots) { @@ -172,7 +193,7 @@ export const runnerCommand: CommandDefinition = { ${chalk.bold('hapi runner')} - Runner management ${chalk.bold('Usage:')} - hapi runner start Start the runner (detached) + hapi runner start Start the runner (replaces existing runner) hapi runner stop Stop the runner (sessions stay alive) hapi runner status Show runner status hapi runner list List active sessions @@ -188,6 +209,7 @@ ${chalk.bold('Options:')} ${chalk.cyan('hapi doctor clean')} ${chalk.bold('Note:')} The runner runs in the background and manages Claude sessions. +Running ${chalk.cyan('hapi runner start')} stops any existing runner first so new flags and environment variables take effect. ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor clean')} `)