[CL-OPS] Bounded graceful drain in-flight runs на SIGINT (снизить server_shutdown churn / Exit code 5), сохранив anti-orphan reap #79

Closed
opened 2026-06-16 11:33:53 +00:00 by andrei · 0 comments
Owner

Что

На pm2 restart (деплой) сервер ловит SIGINT и в shutdown() (server/src/index.ts) СРАЗУ убивает claude-детей (killProcessTree) + отменяет все live runs (prepareActiveRunsForServerShutdown -> server_shutdown), без ожидания завершения. Добавить bounded graceful drain перед kill/cancel, сохранив anti-orphan reap.

Зачем (confirmed root cause)

  • Триггер: агенты сами деплоят платформу (pm2 restart europatech-agents) — 38 deploy-related runs за 6ч, self-inflicted (~1 рестарт/час сейчас, до 7/30мин во время auth-инцидента).
  • pm2 restart -> SIGINT -> shutdown(signal) (index.ts ~1024). Sequence: pluginWorkerManager.prepareForShutdown -> killProcessTree(graceMs:1000) -> heartbeat.prepareActiveRunsForServerShutdown (heartbeat.ts ~5700: каждому активному процессу сразу setRunStatus(cancelled, errorCode='server_shutdown')) -> runShutdownCallbacks -> closeHttpServer -> killProcessTree(graceMs:5000) -> process.exit(0).
  • Эффект: прерванные tool calls в transcript = «TOOL ERRORED / Exit code 5 / INPUT empty» (claude умер посреди run'а); runs ретраятся (потеря 20-40 мин работы).
  • Объём: server_shutdown ~18/4ч + process_lost ~20/4ч = вторая причина errored tool calls (первая — auth, починена).
  • pm2 kill_timeout: 10000 (scripts/daemons/ecosystem.config.cjs:31).

ВАЖНО: текущий design намеренно быстрый — не сломать

killProcessTree на shutdown добавлен НАМЕРЕННО против orphan-leak (комментарий в index.ts): detached agent sessions (script -c claude --tools) reparent to init на pm2 restart и текут — наблюдали 66 orphans / ~5.8GB RSS. Любой drain ОБЯЗАН сохранить «нет orphans после рестарта»: reap стрэгглеров остаётся.

Предлагаемое решение (safe, additive)

  1. heartbeat.drainActiveRunsBeforeShutdown({ timeoutMs }): poll runningProcesses.size пока 0 ИЛИ timeout (claude выходит сам -> меньше reap).
  2. В shutdown() порядок: сначала остановить dispatch новых runs (сейчас clearInterval(heartbeatSchedulerInterval) в runShutdownCallbacks — вызывается ПОЗЖЕ; двинуть стоп-диспатча ПЕРЕД drain, иначе новые runs стартуют и drain не сходится) -> drainActiveRunsBeforeShutdown(timeoutMs) -> дальше как сейчас (killProcessTree + prepareActiveRunsForServerShutdown отменяет ТОЛЬКО переживших drain). Anti-orphan reap сохранён.
  3. Bump pm2 kill_timeout до timeoutMs + буфер.
  4. Конфиг PAPERCLIP_SHUTDOWN_DRAIN_MS (default ~120000) — knob tradeoff.

Tradeoff (policy decision команды)

Текущий design выбрал СКОРОСТЬ (1s/5s grace). Drain добавляет <= timeoutMs к restart. Длинные runs (до 40 мин) всё равно режутся на cap (редки -> приемлемо). Рекомендую 120-180с.

План тестирования (TDD)

  • Unit (mirror server/src/tests/heartbeat-process-recovery.test.ts): mock runningProcesses с N; drain резолвится при size->0 до timeout; при timeout остаток -> prepareActiveRunsForServerShutdown. Edge: 0 процессов; все finish; никто не finish.
  • Регрессия: после рестарта НЕТ orphan claude (anti-leak).
  • Integration: SIGINT при 1 коротком run -> succeeded, не server_shutdown. После деплоя server_shutdown падает.

Где могу ошибаться

  • Не остановить dispatch ПЕРЕД drain -> drain не сходится. Порядок критичен.
  • Большой kill_timeout -> зависший сервер дольше не умирает.
  • Blue-green деплой убрал бы tradeoff полностью, но это крупный рефактор stateful fork-app.

Refs: index.ts (shutdown ~1024, killProcessTree, closeHttpServer ~675), heartbeat.ts (prepareActiveRunsForServerShutdown ~5700, runningProcesses), ecosystem.config.cjs:31, .forgejo/workflows/ci.yml (только build).

## Что На `pm2 restart` (деплой) сервер ловит SIGINT и в `shutdown()` (server/src/index.ts) СРАЗУ убивает claude-детей (`killProcessTree`) + отменяет все live runs (`prepareActiveRunsForServerShutdown` -> `server_shutdown`), без ожидания завершения. Добавить **bounded graceful drain** перед kill/cancel, сохранив anti-orphan reap. ## Зачем (confirmed root cause) - Триггер: агенты сами деплоят платформу (`pm2 restart europatech-agents`) — 38 deploy-related runs за 6ч, self-inflicted (~1 рестарт/час сейчас, до 7/30мин во время auth-инцидента). - `pm2 restart` -> SIGINT -> `shutdown(signal)` (index.ts ~1024). Sequence: `pluginWorkerManager.prepareForShutdown` -> `killProcessTree(graceMs:1000)` -> `heartbeat.prepareActiveRunsForServerShutdown` (heartbeat.ts ~5700: каждому активному процессу сразу `setRunStatus(cancelled, errorCode='server_shutdown')`) -> `runShutdownCallbacks` -> `closeHttpServer` -> `killProcessTree(graceMs:5000)` -> `process.exit(0)`. - Эффект: прерванные tool calls в transcript = «TOOL ERRORED / Exit code 5 / INPUT empty» (claude умер посреди run'а); runs ретраятся (потеря 20-40 мин работы). - Объём: `server_shutdown` ~18/4ч + `process_lost` ~20/4ч = вторая причина errored tool calls (первая — auth, починена). - pm2 `kill_timeout: 10000` (scripts/daemons/ecosystem.config.cjs:31). ## ВАЖНО: текущий design намеренно быстрый — не сломать `killProcessTree` на shutdown добавлен НАМЕРЕННО против orphan-leak (комментарий в index.ts): detached agent sessions (`script -c claude --tools`) reparent to init на pm2 restart и текут — наблюдали 66 orphans / ~5.8GB RSS. Любой drain ОБЯЗАН сохранить «нет orphans после рестарта»: reap стрэгглеров остаётся. ## Предлагаемое решение (safe, additive) 1. `heartbeat.drainActiveRunsBeforeShutdown({ timeoutMs })`: poll `runningProcesses.size` пока 0 ИЛИ timeout (claude выходит сам -> меньше reap). 2. В `shutdown()` порядок: сначала остановить dispatch новых runs (сейчас `clearInterval(heartbeatSchedulerInterval)` в `runShutdownCallbacks` — вызывается ПОЗЖЕ; двинуть стоп-диспатча ПЕРЕД drain, иначе новые runs стартуют и drain не сходится) -> `drainActiveRunsBeforeShutdown(timeoutMs)` -> дальше как сейчас (`killProcessTree` + `prepareActiveRunsForServerShutdown` отменяет ТОЛЬКО переживших drain). Anti-orphan reap сохранён. 3. Bump pm2 `kill_timeout` до `timeoutMs + буфер`. 4. Конфиг `PAPERCLIP_SHUTDOWN_DRAIN_MS` (default ~120000) — knob tradeoff. ## Tradeoff (policy decision команды) Текущий design выбрал СКОРОСТЬ (1s/5s grace). Drain добавляет <= timeoutMs к restart. Длинные runs (до 40 мин) всё равно режутся на cap (редки -> приемлемо). Рекомендую 120-180с. ## План тестирования (TDD) - Unit (mirror server/src/__tests__/heartbeat-process-recovery.test.ts): mock `runningProcesses` с N; drain резолвится при size->0 до timeout; при timeout остаток -> `prepareActiveRunsForServerShutdown`. Edge: 0 процессов; все finish; никто не finish. - Регрессия: после рестарта НЕТ orphan claude (anti-leak). - Integration: SIGINT при 1 коротком run -> `succeeded`, не `server_shutdown`. После деплоя `server_shutdown` падает. ## Где могу ошибаться - Не остановить dispatch ПЕРЕД drain -> drain не сходится. Порядок критичен. - Большой kill_timeout -> зависший сервер дольше не умирает. - Blue-green деплой убрал бы tradeoff полностью, но это крупный рефактор stateful fork-app. Refs: index.ts (shutdown ~1024, killProcessTree, closeHttpServer ~675), heartbeat.ts (prepareActiveRunsForServerShutdown ~5700, runningProcesses), ecosystem.config.cjs:31, .forgejo/workflows/ci.yml (только build).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
europa-tech-srl/europa-tech-agents#79
No description provided.