refactor(daemon): use mtime-based version detection instead of string comparison

Replace CLI version string comparison with file modification time (mtime) based detection. This provides a more reliable way to detect when the CLI binary has been updated, especially for bun-compiled executables where package.json may not be accessible.

Changes:
- Add getInstalledCliMtimeMs() utility to check CLI binary or package.json mtime
- Store startedWithCliMtimeMs in daemon state on startup
- Use mtime comparison in version check loop for daemon auto-restart
- Move version flag handling to early CLI startup before daemon initialization
- Fix machine active state merging to prefer newer activeAt timestamp

This improves daemon restart behavior when CLI is updated via package managers.
This commit is contained in:
weishu
2025-12-23 13:14:15 +08:00
parent c8f7a23f2f
commit f81e585eef
5 changed files with 54 additions and 36 deletions
+31 -8
View File
@@ -6,10 +6,31 @@
import { logger } from '@/ui/logger';
import { clearDaemonState, readDaemonState } from '@/persistence';
import { Metadata } from '@/api/types';
import { projectPath } from '@/projectPath';
import { readFileSync } from 'fs';
import { join } from 'path';
import { configuration } from '@/configuration';
import packageJson from '../../package.json';
import { existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { isBunCompiled, projectPath } from '@/projectPath';
export function getInstalledCliMtimeMs(): number | undefined {
if (isBunCompiled()) {
try {
return statSync(process.execPath).mtimeMs;
} catch {
return undefined;
}
}
const packageJsonPath = join(projectPath(), 'package.json');
if (!existsSync(packageJsonPath)) {
return undefined;
}
try {
return statSync(packageJsonPath).mtimeMs;
} catch {
return undefined;
}
}
async function daemonPost(path: string, body?: any): Promise<{ error?: string } | any> {
const state = await readDaemonState();
@@ -154,11 +175,13 @@ export async function isDaemonRunningCurrentlyInstalledHappyVersion(): Promise<b
}
try {
// Read package.json on demand from disk - so we are guaranteed to get the latest version
const packageJsonPath = join(projectPath(), 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const currentCliMtimeMs = getInstalledCliMtimeMs();
if (typeof currentCliMtimeMs === 'number' && typeof state.startedWithCliMtimeMs === 'number') {
logger.debug(`[DAEMON CONTROL] Current CLI mtime: ${currentCliMtimeMs}, Daemon started with mtime: ${state.startedWithCliMtimeMs}`);
return currentCliMtimeMs === state.startedWithCliMtimeMs;
}
const currentCliVersion = packageJson.version;
logger.debug(`[DAEMON CONTROL] Current CLI version: ${currentCliVersion}, Daemon started with version: ${state.startedWithCliVersion}`);
return currentCliVersion === state.startedWithCliVersion;
+10 -7
View File
@@ -13,11 +13,10 @@ import { getEnvironmentInfo } from '@/ui/doctor';
import { spawnHappyCLI } from '@/utils/spawnHappyCLI';
import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock } from '@/persistence';
import { cleanupDaemonState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient';
import { cleanupDaemonState, getInstalledCliMtimeMs, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient';
import { startDaemonControlServer } from './controlServer';
import { readFileSync } from 'fs';
import { join } from 'path';
import { projectPath, runtimePath } from '@/projectPath';
import { runtimePath } from '@/projectPath';
// Prepare initial metadata
export const initialMachineMetadata: MachineMetadata = {
@@ -403,12 +402,15 @@ export async function startDaemon(): Promise<void> {
onHappySessionWebhook
});
const startedWithCliMtimeMs = getInstalledCliMtimeMs();
// Write initial daemon state (no lock needed for state file)
const fileState: DaemonLocallyPersistedState = {
pid: process.pid,
httpPort: controlPort,
startTime: new Date().toLocaleString(),
startedWithCliVersion: packageJson.version,
startedWithCliMtimeMs,
daemonLogPath: logger.logFilePath
};
writeDaemonState(fileState);
@@ -476,10 +478,10 @@ export async function startDaemon(): Promise<void> {
}
// Check if daemon needs update
// If version on disk is different from the one in package.json - we need to restart
// BIG if - does this get updated from underneath us on npm upgrade?
const projectVersion = JSON.parse(readFileSync(join(projectPath(), 'package.json'), 'utf-8')).version;
if (projectVersion !== configuration.currentCliVersion) {
const installedCliMtimeMs = getInstalledCliMtimeMs();
if (typeof installedCliMtimeMs === 'number' &&
typeof startedWithCliMtimeMs === 'number' &&
installedCliMtimeMs !== startedWithCliMtimeMs) {
logger.debug('[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval');
clearInterval(restartOnStaleVersionAndHeartbeat);
@@ -521,6 +523,7 @@ export async function startDaemon(): Promise<void> {
httpPort: controlPort,
startTime: fileState.startTime,
startedWithCliVersion: packageJson.version,
startedWithCliMtimeMs,
lastHeartbeat: new Date().toLocaleString(),
daemonLogPath: fileState.daemonLogPath
};
+6 -19
View File
@@ -52,6 +52,11 @@ import { withBunRuntimeEnv } from './utils/bunRuntime'
return process.argv.slice(1)
})()
if (args.includes('-v') || args.includes('--version')) {
console.log(`hapi version: ${packageJson.version}`)
process.exit(0)
}
// Check if first argument is a subcommand
const subcommand = args[0]
@@ -79,10 +84,7 @@ import { withBunRuntimeEnv } from './utils/bunRuntime'
await ensureRuntimeAssets()
// If --version is passed - do not log, its likely daemon inquiring about our version
if (!args.includes('--version')) {
logger.debug('Starting hapi CLI with args: ', process.argv)
}
logger.debug('Starting hapi CLI with args: ', process.argv)
if (subcommand === 'doctor') {
// Check for clean subcommand
@@ -318,7 +320,6 @@ ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor cl
// Parse command line arguments for main command
const options: StartOptions = {}
let showHelp = false
let showVersion = false
const unknownArgs: string[] = [] // Collect unknown args to pass through to claude
for (let i = 0; i < args.length; i++) {
@@ -328,10 +329,6 @@ ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor cl
showHelp = true
// Also pass through to claude
unknownArgs.push(arg)
} else if (arg === '-v' || arg === '--version') {
showVersion = true
// Also pass through to claude (will show after our version)
unknownArgs.push(arg)
} else if (arg === '--hapi-starting-mode') {
options.startingMode = z.enum(['local', 'remote']).parse(args[++i])
} else if (arg === '--yolo') {
@@ -404,16 +401,6 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}
process.exit(0)
}
// Show version
if (showVersion) {
console.log(`hapi version: ${packageJson.version}`)
const versionOnly = args.every((value) => value === '-v' || value === '--version')
if (versionOnly) {
process.exit(0)
}
// Continue to pass --version to Claude Code when other args are present.
}
// Normal flow - auth and machine setup
await initializeToken();
await authAndSetupMachineIfNeeded();
+1
View File
@@ -35,6 +35,7 @@ export interface DaemonLocallyPersistedState {
httpPort: number;
startTime: string;
startedWithCliVersion: string;
startedWithCliMtimeMs?: number;
lastHeartbeat?: string;
daemonLogPath?: string;
}
+6 -2
View File
@@ -476,13 +476,17 @@ export class SyncEngine {
return { host, platform, happyCliVersion, displayName, ...data }
})()
const storedActiveAt = stored.activeAt ?? stored.createdAt
const existingActiveAt = existing?.activeAt ?? 0
const useStoredActivity = storedActiveAt > existingActiveAt
const machine: Machine = {
id: stored.id,
seq: stored.seq,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
active: existing?.active ?? stored.active,
activeAt: existing?.activeAt ?? (stored.activeAt ?? stored.createdAt),
active: useStoredActivity ? stored.active : (existing?.active ?? stored.active),
activeAt: useStoredActivity ? storedActiveAt : (existingActiveAt || storedActiveAt),
metadata,
metadataVersion: stored.metadataVersion,
daemonState: stored.daemonState,