feat: implement unified local launch failure handling policy

This commit is contained in:
weishu
2025-12-26 16:11:58 +08:00
parent 685c846b91
commit eee7a249fa
9 changed files with 149 additions and 21 deletions
+13 -4
View File
@@ -3,6 +3,7 @@ import { claudeLocal } from "./claudeLocal";
import { Session } from "./session";
import { Future } from "@/utils/future";
import { createSessionScanner } from "./utils/sessionScanner";
import { getLocalLaunchExitReason } from "@/agent/localLaunchPolicy";
export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> {
@@ -112,12 +113,20 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
}
} catch (e) {
logger.debug('[local]: launch error', e);
const message = e instanceof Error ? e.message : String(e);
session.client.sendSessionEvent({ type: 'message', message: `Local Claude process failed: ${message}` });
const failureExitReason = exitReason ?? getLocalLaunchExitReason({
startedBy: session.startedBy,
startingMode: session.startingMode
});
session.recordLocalLaunchFailure(message, failureExitReason);
if (!exitReason) {
session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' });
continue;
} else {
break;
exitReason = failureExitReason;
}
if (failureExitReason === 'exit') {
logger.warn(`[local]: Local Claude process failed: ${message}`);
}
break;
}
logger.debug('[local]: launch done');
}
+6 -1
View File
@@ -24,6 +24,7 @@ interface LoopOptions {
model?: string
permissionMode?: PermissionMode
startingMode?: 'local' | 'remote'
startedBy?: 'daemon' | 'terminal'
onModeChange: (mode: 'local' | 'remote') => void
mcpServers: Record<string, any>
session: ApiSessionClient
@@ -40,6 +41,8 @@ export async function loop(opts: LoopOptions) {
// Get log path for debug display
const logPath = logger.logFilePath;
const startedBy = opts.startedBy ?? 'terminal';
const startingMode = opts.startingMode ?? 'local';
let session = new Session({
api: opts.api,
client: opts.session,
@@ -52,7 +55,9 @@ export async function loop(opts: LoopOptions) {
messageQueue: opts.messageQueue,
allowedTools: opts.allowedTools,
onModeChange: opts.onModeChange,
mode: opts.startingMode,
mode: startingMode,
startedBy,
startingMode,
hookSettingsPath: opts.hookSettingsPath
});
+40 -9
View File
@@ -37,13 +37,14 @@ export interface StartOptions {
export async function runClaude(options: StartOptions = {}): Promise<void> {
const workingDirectory = process.cwd();
const sessionTag = randomUUID();
const startedBy = options.startedBy ?? 'terminal';
// Log environment info at startup
logger.debugLargeJson('[START] HAPI process started', getEnvironmentInfo());
logger.debug(`[START] Options: startedBy=${options.startedBy}, startingMode=${options.startingMode}`);
logger.debug(`[START] Options: startedBy=${startedBy}, startingMode=${options.startingMode}`);
// Validate daemon spawn requirements
if (options.startedBy === 'daemon' && options.startingMode === 'local') {
if (startedBy === 'daemon' && options.startingMode === 'local') {
logger.debug('Daemon spawn requested with local mode - forcing remote mode');
options.startingMode = 'remote';
// TODO: Eventually we should error here instead of silently switching
@@ -81,9 +82,9 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
happyHomeDir: configuration.happyHomeDir,
happyLibDir: runtimePath(),
happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'),
startedFromDaemon: options.startedBy === 'daemon',
startedFromDaemon: startedBy === 'daemon',
hostPid: process.pid,
startedBy: options.startedBy || 'terminal',
startedBy,
// Initialize lifecycle state
lifecycleState: 'running',
lifecycleStateSince: Date.now(),
@@ -130,6 +131,16 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
// Variable to track current session instance (updated via onSessionReady callback)
let currentSession: Session | null = null;
let exitCode = 0;
let archiveReason: string | null = null;
const formatFailureReason = (message: string): string => {
const maxLength = 200;
if (message.length <= maxLength) {
return message;
}
return `${message.slice(0, maxLength)}...`;
};
// Start Hook server for receiving Claude session notifications
const hookServer = await startHookServer({
@@ -156,9 +167,10 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
logger.infoDeveloper(`Logs: ${logPath}`);
// Set initial agent state
const startingMode = options.startingMode ?? (startedBy === 'daemon' ? 'remote' : 'local');
session.updateAgentState((currentState) => ({
...currentState,
controlledByUser: options.startingMode !== 'remote'
controlledByUser: startingMode !== 'remote'
}));
// Import MessageQueue2 and create message queue
@@ -314,12 +326,13 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
try {
// Update lifecycle state to archived before closing
if (session) {
const reason = archiveReason ?? 'User terminated';
session.updateMetadata((currentMetadata) => ({
...currentMetadata,
lifecycleState: 'archived',
lifecycleStateSince: Date.now(),
archivedBy: 'cli',
archiveReason: 'User terminated'
archiveReason: reason
}));
// Send session death message
@@ -336,7 +349,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
cleanupHookSettingsFile(hookSettingsPath);
logger.debug('[START] Cleanup complete, exiting');
process.exit(0);
process.exit(exitCode);
} catch (error) {
logger.debug('[START] Error during cleanup:', error);
process.exit(1);
@@ -350,11 +363,15 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
// Handle uncaught exceptions and rejections
process.on('uncaughtException', (error) => {
logger.debug('[START] Uncaught exception:', error);
exitCode = 1;
archiveReason = 'Session crashed';
cleanup();
});
process.on('unhandledRejection', (reason) => {
logger.debug('[START] Unhandled rejection:', reason);
exitCode = 1;
archiveReason = 'Session crashed';
cleanup();
});
@@ -365,7 +382,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
path: workingDirectory,
model: options.model,
permissionMode: options.permissionMode,
startingMode: options.startingMode,
startingMode,
messageQueue,
api,
allowedTools: happyServer.toolNames.map(toolName => `mcp__hapi__${toolName}`),
@@ -388,9 +405,23 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
session,
claudeEnvVars: options.claudeEnvVars,
claudeArgs: options.claudeArgs,
startedBy,
hookSettingsPath
});
const localFailure = currentSession?.localLaunchFailure;
if (localFailure?.exitReason === 'exit') {
exitCode = 1;
archiveReason = `Local launch failed: ${formatFailureReason(localFailure.message)}`;
session.updateMetadata((currentMetadata) => ({
...currentMetadata,
lifecycleState: 'archived',
lifecycleStateSince: Date.now(),
archivedBy: 'cli',
archiveReason
}));
}
// Send session death message
session.sendSessionDeath();
@@ -412,5 +443,5 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
logger.debug('Stopped Hook server and cleaned up settings file');
// Exit
process.exit(0);
process.exit(exitCode);
}
+17
View File
@@ -3,6 +3,12 @@ import { MessageQueue2 } from '@/utils/MessageQueue2';
import { logger } from '@/ui/logger';
import { AgentSessionBase } from '@/agent/sessionBase';
import type { EnhancedMode } from './loop';
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
type LocalLaunchFailure = {
message: string;
exitReason: LocalLaunchExitReason;
};
export class Session extends AgentSessionBase<EnhancedMode> {
readonly claudeEnvVars?: Record<string, string>;
@@ -10,6 +16,9 @@ export class Session extends AgentSessionBase<EnhancedMode> {
readonly mcpServers: Record<string, any>;
readonly allowedTools?: string[];
readonly hookSettingsPath: string;
readonly startedBy: 'daemon' | 'terminal';
readonly startingMode: 'local' | 'remote';
localLaunchFailure: LocalLaunchFailure | null = null;
constructor(opts: {
api: ApiClient;
@@ -24,6 +33,8 @@ export class Session extends AgentSessionBase<EnhancedMode> {
onModeChange: (mode: 'local' | 'remote') => void;
allowedTools?: string[];
mode?: 'local' | 'remote';
startedBy: 'daemon' | 'terminal';
startingMode: 'local' | 'remote';
hookSettingsPath: string;
}) {
super({
@@ -48,8 +59,14 @@ export class Session extends AgentSessionBase<EnhancedMode> {
this.mcpServers = opts.mcpServers;
this.allowedTools = opts.allowedTools;
this.hookSettingsPath = opts.hookSettingsPath;
this.startedBy = opts.startedBy;
this.startingMode = opts.startingMode;
}
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
this.localLaunchFailure = { message, exitReason };
};
/**
* Clear the current session ID (used by /clear command)
*/