Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions ecosystem.config.cjs
Original file line number Diff line number Diff line change
@@ -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',
},
}],
};
2 changes: 1 addition & 1 deletion src/claude/__tests__/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
Expand Down
18 changes: 16 additions & 2 deletions src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface ExecuteInput extends ExecuteOptions {
historySummaries?: string;
/** 覆盖 system prompt(用于 pipeline 各角色独立 prompt) */
systemPromptOverride?: string;
/** 覆盖默认超时秒数 (默认使用 CLAUDE_TIMEOUT 配置) */
timeoutSeconds?: number;
}

/** 构建工作区管理系统提示词(注入实际目录路径) */
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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,
Expand All @@ -286,6 +298,8 @@ export class ClaudeExecutor {
};
}

clearTimeout(timer);

// 等待最后一个流式更新完成,防止与最终卡片更新竞态
if (lastStreamPromise) await lastStreamPromise.catch(() => {});

Expand Down
32 changes: 18 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...');
Expand All @@ -25,8 +26,9 @@ function main(): void {
timeoutSeconds: config.claude.timeoutSeconds,
}, 'Configuration loaded');

// 启动时清理残留的 .tmp-* 临时目录
// 启动时清理残留的 .tmp-* 临时目录和孤儿 Claude 子进程
cleanupTmpDirs();
killOrphanedClaudeProcesses();

// 启动 HTTP 服务
startServer();
Expand All @@ -37,31 +39,33 @@ function main(): void {
});

// 定时清理过期会话、Claude Code 进程、缓存和管道记录 (每 30 分钟)
setInterval(() => {
const cleanupInterval = setInterval(() => {
sessionManager.cleanup();
claudeExecutor.cleanup();
cleanupExpiredCaches();
pipelineStore.cleanExpired(30);
}, 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);
Comment thread
claude[bot] marked this conversation as resolved.
}

process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}

main();
61 changes: 61 additions & 0 deletions src/utils/process-cleanup.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
claude[bot] marked this conversation as resolved.

// 仅清理真正的孤儿进程(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;
}