mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(cli): replace existing runner on start (#754)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')}
|
||||
`)
|
||||
|
||||
Reference in New Issue
Block a user