'use strict'; // ============================================================================ // AI 자식 프로세스 PID 기록 (v1.6.31, 요구 12) — 서비스가 죽거나 재시작돼도 뒤에 남은 claude/codex 작업을 다음 기동 때 정리한다. // 자식은 detached 프로세스 그룹으로 실행되므로(취소 때 훅·하위까지 한 번에 종료) 서비스가 비정상 종료되면 그대로 살아남을 수 있었다. // 저장: data/run/jobs.json { pid: { kind, siteDir, at } }. 기동 때 sweep(): 아직 살아 있고 시작 시각이 일치하는 프로세스 그룹에 SIGTERM → 2초 뒤 SIGKILL. // ============================================================================ const fs = require('fs'); const path = require('path'); const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); const FILE = path.join(DATA_ROOT, 'run', 'jobs.json'); function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch (_) { return {}; } } function save(d) { try { fs.mkdirSync(path.dirname(FILE), { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(d)); } catch (_) {} } function add(pid, info) { const d = load(); d[pid] = { ...info, at: Date.now(), host: process.pid }; save(d); } function remove(pid) { const d = load(); if (d[pid]) { delete d[pid]; save(d); } } function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } /** 기동 때: 이전 서비스 프로세스가 남긴 자식 중 살아 있는 것을 끝낸다. @returns {number} 정리한 수 */ function sweep() { const d = load(); let n = 0; for (const [pidStr, info] of Object.entries(d)) { const pid = Number(pidStr); if (!pid || info.host === process.pid) continue; if (alive(pid)) { let cmd = ''; try { cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); } catch (_) {} if (!cmd || /claude|codex/.test(cmd)) { try { process.kill(-pid, 'SIGTERM'); } catch (_) { try { process.kill(pid, 'SIGTERM'); } catch (_) {} } setTimeout(() => { if (alive(pid)) { try { process.kill(-pid, 'SIGKILL'); } catch (_) { try { process.kill(pid, 'SIGKILL'); } catch (_) {} } } }, 2000).unref(); n += 1; } } delete d[pidStr]; } save(d); return n; } module.exports = { add, remove, sweep, alive, FILE };