fix(claude): align stop-hook timeout with Claude-side bound and add PID liveness check - #717
Conversation
…add liveness check The workflow's `_claude-stop-hook` had its own 15-minute wait budget for the queue/release poll loop — even after #713 extended the Claude-side hook timeout to ~24 days, a turn with no follow-up prompt inside 15 min would strand the session: the hook returned 0, Claude stopped, and the next `enqueuePrompt` wrote to a file nobody was reading. - Raise `DEFAULT_WAIT_TIMEOUT_MS` to match `STOP_HOOK_TIMEOUT_SECONDS` so both bounds are unified. - Replace the 100ms `existsSync` poll with `fs.watch` on the queue/release dirs (plus a slower existsSync fallback for dropped inotify events), so the next-turn prompt is delivered with ~0 latency. - Add an atomic-PID liveness check: `createClaudeSession` now writes `~/.atomic/claude-pid/<session_id>` containing `process.pid`, and the hook polls `process.kill(pid, 0)` every 5s. If atomic is SIGKILL'd without running teardown, the hook self-exits instead of parking Claude for the full 24-day budget. `clearClaudeSession` unlinks the pid file on graceful shutdown. Missing pid files are tolerated (liveness is skipped), so non-runtime hook invocations still work. - Add `abortableSleep` so the cooperating wait tasks cancel immediately when any one of them detects a hit.
Code Review — PR #717Nice hardening of the Stop hook. The liveness signal closes a real hole (workflow SIGKILL leaving the hook parked for 24 days), and the ✅ What's working well
🐛 Potential issues1. // Best-effort; failures just mean the hook falls back to waiting out Claude's own hook timeout.
await writeAtomicPidFile(claudeSessionId);The comment says failures are tolerated, but the call is unwrapped — an 2. PID reuse false-positives ( 3. Tiny watcher-attach race on 4. Dead-PID scan in the test can be slow under load ( 🔐 Security
🧪 Test coverageGood addition of test #9 for the dead-PID path. Gaps worth considering:
📝 Nits
SummaryOverall a solid fix with thoughtful fallback layering (watcher → poll → liveness → 24-day ceiling). Primary ask: wrap |
* fix(claude): align stop-hook wait bound with Claude-side timeout and add liveness check The workflow's `_claude-stop-hook` had its own 15-minute wait budget for the queue/release poll loop — even after #713 extended the Claude-side hook timeout to ~24 days, a turn with no follow-up prompt inside 15 min would strand the session: the hook returned 0, Claude stopped, and the next `enqueuePrompt` wrote to a file nobody was reading. - Raise `DEFAULT_WAIT_TIMEOUT_MS` to match `STOP_HOOK_TIMEOUT_SECONDS` so both bounds are unified. - Replace the 100ms `existsSync` poll with `fs.watch` on the queue/release dirs (plus a slower existsSync fallback for dropped inotify events), so the next-turn prompt is delivered with ~0 latency. - Add an atomic-PID liveness check: `createClaudeSession` now writes `~/.atomic/claude-pid/<session_id>` containing `process.pid`, and the hook polls `process.kill(pid, 0)` every 5s. If atomic is SIGKILL'd without running teardown, the hook self-exits instead of parking Claude for the full 24-day budget. `clearClaudeSession` unlinks the pid file on graceful shutdown. Missing pid files are tolerated (liveness is skipped), so non-runtime hook invocations still work. - Add `abortableSleep` so the cooperating wait tasks cancel immediately when any one of them detects a hit. * chore(release): bump version to v0.5.28-1
* fix(claude): align stop-hook wait bound with Claude-side timeout and add liveness check The workflow's `_claude-stop-hook` had its own 15-minute wait budget for the queue/release poll loop — even after #713 extended the Claude-side hook timeout to ~24 days, a turn with no follow-up prompt inside 15 min would strand the session: the hook returned 0, Claude stopped, and the next `enqueuePrompt` wrote to a file nobody was reading. - Raise `DEFAULT_WAIT_TIMEOUT_MS` to match `STOP_HOOK_TIMEOUT_SECONDS` so both bounds are unified. - Replace the 100ms `existsSync` poll with `fs.watch` on the queue/release dirs (plus a slower existsSync fallback for dropped inotify events), so the next-turn prompt is delivered with ~0 latency. - Add an atomic-PID liveness check: `createClaudeSession` now writes `~/.atomic/claude-pid/<session_id>` containing `process.pid`, and the hook polls `process.kill(pid, 0)` every 5s. If atomic is SIGKILL'd without running teardown, the hook self-exits instead of parking Claude for the full 24-day budget. `clearClaudeSession` unlinks the pid file on graceful shutdown. Missing pid files are tolerated (liveness is skipped), so non-runtime hook invocations still work. - Add `abortableSleep` so the cooperating wait tasks cancel immediately when any one of them detects a hit. * chore(release): bump version to v0.5.28-1
Summary
Fixes a session-stranding bug where the
_claude-stop-hookhad a 15-minute wait budget that expired long before the Claude-side hook timeout (~24 days), causingenqueuePromptto write to a file no one was reading. Also adds an atomic-PID liveness check so the hook can self-exit if atomic is SIGKILL'd without running teardown.Key Changes
DEFAULT_WAIT_TIMEOUT_MSfrom 15 minutes to2_147_483_000 ms(~24 days) to matchSTOP_HOOK_TIMEOUT_SECONDS, eliminating the mismatch between hook-side and Claude-side wait bounds.fs.watch-based delivery: Replace the 100 msexistsSyncpoll loop withfs.watchwatchers on the queue and release directories for near-zero latency prompt delivery, with a slower polling fallback for dropped inotify/FSEvent notifications.createClaudeSessionnow writesprocess.pidto~/.atomic/claude-pid/<session_id>; the stop hook pollsprocess.kill(pid, 0)every 5s. If atomic is SIGKILL'd, the hook detects the dead PID and self-exits instead of parking Claude for the full timeout.clearClaudeSessionunlinks the pid file on graceful shutdown. Missing pid files are tolerated (liveness is skipped).abortableSleep: New helper that resolves immediately onAbortSignalabort, allowing any one winning wait task to cancel its siblings instantly.claude-stop-hook.test.tsto cover the newpiddirectory, and added a test verifying that a dead atomic PID triggers liveness exit before the full wait timeout.Files Changed
src/commands/cli/claude-stop-hook.ts— core wait loop rewrite with watcher, liveness check, and timeout alignmentsrc/commands/cli/claude-stop-hook.test.ts— updated cleanup and new liveness testsrc/sdk/providers/claude.ts—writeAtomicPidFile/unlinkAtomicPidFileintegrated into session lifecycle