diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 00000000..6c59c224 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,16 @@ +module.exports = { + apps: [{ + name: 'feishu-claude', + script: 'dist/index.js', + + // 给 shutdown handler 足够时间清理子进程(默认 1600ms 太短) + kill_timeout: 10000, + + // 内存超限自动重启 + max_memory_restart: '1G', + + env: { + NODE_ENV: 'production', + }, + }], +}; diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts index 44bebdbd..bb5f6209 100644 --- a/src/claude/__tests__/executor.test.ts +++ b/src/claude/__tests__/executor.test.ts @@ -12,7 +12,7 @@ vi.mock('node:fs', () => ({ vi.mock('../../config.js', () => ({ config: { - claude: { defaultWorkDir: '/tmp/work' }, + claude: { defaultWorkDir: '/tmp/work', timeoutSeconds: 300 }, repoCache: { dir: '/repos/cache' }, workspace: { baseDir: '/tmp/workspaces' }, }, diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 5031929e..96f502b5 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -26,6 +26,8 @@ export interface ExecuteInput extends ExecuteOptions { historySummaries?: string; /** 覆盖 system prompt(用于 pipeline 各角色独立 prompt) */ systemPromptOverride?: string; + /** 覆盖默认超时秒数 (默认使用 CLAUDE_TIMEOUT 配置) */ + timeoutSeconds?: number; } /** 构建工作区管理系统提示词(注入实际目录路径) */ @@ -126,6 +128,13 @@ export class ClaudeExecutor { const startTime = Date.now(); const abortController = new AbortController(); + const timeoutMs = (input.timeoutSeconds ?? config.claude.timeoutSeconds) * 1000; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + abortController.abort(); + logger.warn({ sessionKey, timeoutMs }, 'Claude query timed out, aborting'); + }, timeoutMs); // 确保工作目录存在,否则 spawn 会报 ENOENT if (!existsSync(workingDir)) { @@ -271,11 +280,14 @@ export class ClaudeExecutor { } } } catch (err) { + clearTimeout(timer); this.runningQueries.delete(sessionKey); const durationMs = Date.now() - startTime; - const errorMsg = err instanceof Error ? err.message : String(err); - logger.error({ sessionKey, err: errorMsg }, 'Claude Agent SDK query error'); + const errorMsg = timedOut + ? `Query timed out after ${timeoutMs / 1000}s` + : (err instanceof Error ? err.message : String(err)); + logger.error({ sessionKey, err: errorMsg, timedOut }, 'Claude Agent SDK query error'); return { success: false, @@ -286,6 +298,8 @@ export class ClaudeExecutor { }; } + clearTimeout(timer); + // 等待最后一个流式更新完成,防止与最终卡片更新竞态 if (lastStreamPromise) await lastStreamPromise.catch(() => {}); diff --git a/src/index.ts b/src/index.ts index b09c39ff..78633ce8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { claudeExecutor } from './claude/executor.js'; import { cleanupTmpDirs, cleanupExpiredCaches } from './workspace/cache.js'; import { pipelineStore } from './pipeline/store.js'; import { recoverInterruptedPipelines } from './pipeline/runner.js'; +import { killOrphanedClaudeProcesses } from './utils/process-cleanup.js'; function main(): void { logger.info('Starting Feishu Claude Code Bridge...'); @@ -25,8 +26,9 @@ function main(): void { timeoutSeconds: config.claude.timeoutSeconds, }, 'Configuration loaded'); - // 启动时清理残留的 .tmp-* 临时目录 + // 启动时清理残留的 .tmp-* 临时目录和孤儿 Claude 子进程 cleanupTmpDirs(); + killOrphanedClaudeProcesses(); // 启动 HTTP 服务 startServer(); @@ -37,7 +39,7 @@ function main(): void { }); // 定时清理过期会话、Claude Code 进程、缓存和管道记录 (每 30 分钟) - setInterval(() => { + const cleanupInterval = setInterval(() => { sessionManager.cleanup(); claudeExecutor.cleanup(); cleanupExpiredCaches(); @@ -45,23 +47,25 @@ function main(): void { }, 30 * 60 * 1000); // 优雅退出 - process.on('SIGINT', () => { - logger.info('Received SIGINT, shutting down...'); - claudeExecutor.killAll(); - pipelineStore.markRunningAsInterrupted(); - pipelineStore.close(); - sessionManager.close(); - process.exit(0); - }); + let shuttingDown = false; - process.on('SIGTERM', () => { - logger.info('Received SIGTERM, shutting down...'); + function shutdown(signal: string): void { + if (shuttingDown) return; + shuttingDown = true; + + logger.info({ signal }, 'Shutting down...'); + clearInterval(cleanupInterval); claudeExecutor.killAll(); pipelineStore.markRunningAsInterrupted(); pipelineStore.close(); sessionManager.close(); - process.exit(0); - }); + + // 给子进程时间响应 SIGTERM 后再退出(PM2 kill_timeout 内) + setTimeout(() => process.exit(0), 3000); + } + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); } main(); diff --git a/src/utils/process-cleanup.ts b/src/utils/process-cleanup.ts new file mode 100644 index 00000000..9dab8d4d --- /dev/null +++ b/src/utils/process-cleanup.ts @@ -0,0 +1,61 @@ +import { execSync } from 'node:child_process'; +import { logger } from './logger.js'; + +/** + * 启动时清理上一次残留的 Claude Code 子进程。 + * PM2 SIGKILL 或服务崩溃后,Agent SDK spawn 的子进程可能成为孤儿进程。 + */ +export function killOrphanedClaudeProcesses(): number { + const myPid = process.pid; + let killed = 0; + + try { + // pgrep -fa claude: 列出命令行包含 "claude" 的进程 (PID + cmdline) + const output = execSync('pgrep -fa claude 2>/dev/null || true', { + encoding: 'utf-8', + timeout: 5000, + }).trim(); + + if (!output) return 0; + + for (const line of output.split('\n')) { + const match = line.match(/^(\d+)\s+(.*)$/); + if (!match) continue; + + const pid = parseInt(match[1], 10); + const cmdline = match[2]; + + // 跳过自身 + if (pid === myPid) continue; + + // 只匹配 Claude Code CLI 进程(命令第一段以 claude 结尾) + // 例如: /usr/local/bin/claude --flags... 或 claude --flags... + const cmd = cmdline.split(/\s/)[0]; + const basename = cmd.split('/').pop(); + if (basename !== 'claude') continue; + + // 仅清理真正的孤儿进程(PPID=1 表示父进程已退出,被 init 接管) + try { + const ppid = execSync(`ps -o ppid= -p ${pid} 2>/dev/null`, { encoding: 'utf-8' }).trim(); + if (ppid !== '1') continue; + } catch { + continue; // 无法获取 PPID,跳过 + } + + try { + process.kill(pid, 'SIGTERM'); + killed++; + logger.info({ pid, cmdline: cmdline.slice(0, 120) }, 'Killed orphaned Claude process'); + } catch { + // 进程已退出 + } + } + } catch { + // pgrep 不可用或其他错误 — 非关键,跳过 + } + + if (killed > 0) { + logger.info({ killed }, 'Cleaned up orphaned Claude processes on startup'); + } + return killed; +}