fix(cli): replace existing runner on start (#754)

This commit is contained in:
junes
2026-05-31 19:34:58 +08:00
committed by GitHub
parent c09bbaed3d
commit 31dd4353d4
2 changed files with 152 additions and 1 deletions
+129
View File
@@ -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<typeof import('node:fs')>('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()
}
})
})
+23 -1
View File
@@ -70,6 +70,17 @@ function extractWorkspaceRootArgs(args: string[]): string[] | undefined {
return uniqueWorkspaceRoots.length > 0 ? uniqueWorkspaceRoots : undefined
}
async function waitForRunnerToStop(maxAttempts = 50): Promise<boolean> {
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')}
`)