Repository navigation
Durable session lifecycle hooks via a unified hook spool - #187
Conversation
Spec for spooling failed session-start/session-end POSTs to disk and draining them cross-session on subsequent hook invocations, so a brief server outage (e.g. a deploy window) during a lifecycle hook no longer strands sessions as Active. CLI-side only; unifies the existing Cursor spool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Root cause: bounded lifecycle POST (single PostOnceAsync clamped to a shared
per-invocation hook deadline) so a hung server is spooled, not killed mid-POST
- Drain before the current event (preserves lifecycle order; matches Cursor test)
- Replayed session-end side effects (generate_whats_done) handled in the
vendor-owned poster closure
- Cursor keeps all four SpoolOnFailure events (no narrowing)
- Migration transforms old {hook_event_name} lines to {route} so the backlog
is not discarded
- Add tests: slow/hung server, drain-before ordering, generate_whats_done
replay, Cursor regression + migration transform
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Deadline discipline now covers auth/client creation (15s token-lock wait + untimed /auth/refresh) via a hard outer cap; spool prepared before the client so delivery failure never loses data - DrainAllAsync drains the current session's file first and spools the fresh event if that backlog can't fully drain (guarantees same-session order) - Atomic rotate-on-drain replaces snapshot-then-rewrite (fixes concurrent append-during-drain loss); orphaned temps recovered next run - Tri-state DrainOutcome (Delivered/Drop/TransientStop) so a permanent 4xx can't poison the head of the queue - Concurrency-safe Cursor migration; clarify "remaining" = to safety-adjusted deadline; add tests for each Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kcap import does NOT end an already-loaded session — its resume branch (ImportCommand.cs:2124) backfills transcript only; session-end is posted just on the New-session branch. Verified empirically recovering 9dc27753 (import "Resumed 1" left it Active; replaying the SessionEnd hook ended it). Manual hatch is the hook replay; the drainer auto-recovers by re-POSTing session-end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Deadline starts at Program.cs Main (before ResolveServerUrl's git shell-outs), not in Handle; hook-path server-URL resolution bounded by it - Build a minimal normalized body + spawn the watcher BEFORE git/gh/auth; repo enrichment only with remaining headroom (repo also arrives via watcher) - Rotate-on-drain: recovered .draining temps drain before the live file, oldest-first (preserves same-session FIFO across the rotate boundary) - Cursor migration merges via HookSpool.Append (no skip-or-clobber/loss) - Auth covered by a LOCAL hard cap; TokenStore unchanged (deeper cleanup is a follow-up) - Tests for budget-pressure-at-entry minimal-body/enrichment-skip Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 TDD tasks: HookSpool component, Cursor migration, process-start deadline + bootstrap bounding, ClaudeHookCommand test seam, session-end/session-start durability, cross-session drain, integration round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ri-state) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…richment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract HandleCore(HttpClient, HookSpool, long, string, TextReader, Task?) as an internal test seam (injected client, no CreateAuthenticatedClientAsync), add HandleWithDeps for production client construction, keep Handle() as the public entry with spool init and delegation. Add WithHardCap identical in semantics to CursorHookCommand.WithHardCap. No behavior change for real invocations; 3 new unit tests confirm session-start route + hard-cap semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restructures session-start handling in ClaudeHookCommand: watcher is spawned before any slow enrichment/POST so transcript capture is never lost. Bounded POST via PostLifecycleBoundedAsync; transient failures are spooled (session-start body with session_id) and return 0. On success a second PostOnceAsync reads the response for envelope/plan-content emission (preserving existing stdout behavior). EnrichWithRepositoryInfo for session-start now receives HookBudget.Remaining budget so it self-skips under deadline pressure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…, whats-done on replay) - HookSpool: expose internal Dir property so callers/tests can locate the spool dir - ClaudeHookCommand: add ClaudePoster closure (maps HTTP outcomes to DrainOutcome, handles generate_whats_done on replayed session-end) - ClaudeHookCommand: drain stranded lifecycle events before each fresh hook POST (current session first; capped at min(2s, remaining budget); fail-open) - ClaudeHookCommand: CurrentSessionHasBacklog ordering guard in session-start and session-end blocks prevents a fresh event from overtaking a stranded predecessor - Tests: three new TDD tests (backlog drained on next hook, ordering guarantee, whats-done on replay); fixture RespondJson for stub response body control Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SpoolOutageRecoveryTests with two end-to-end scenarios:
1. Outage_then_recovery_drains_spooled_session_end — session-end POST
against a 503 server is spooled; a subsequent Stop hook against a 200
server drains the backlog (server receives /hooks/session-end, spool
file deleted).
2. Replayed_session_end_with_generate_whats_done_true_drains_without_error
— pre-seeded spool entry, server responds {"generate_whats_done":true};
asserts POST received and spool emptied (the SpawnWhatsDoneGenerator
side effect is fire-and-forget and not asserted).
Uses HandleCore + a real HttpClient pointing at WireMock and a HookSpool
pinned to a temp dir — hermetic, no auth, no live server.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tcher-before-enrichment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR Summary by QodoDurable lifecycle hook delivery via on-disk HookSpool + hook-time budgets Description
Diagram
High-Level Assessment
Files changed (18)
|
Code Review by Qodo
1.
|
# Conflicts: # src/Capacitor.Cli/Commands/ClaudeHookCommand.cs
…ol cap, README - CreateClientWithinBudgetAsync: the abandoned (timed-out) client-factory task is now observed for ALL terminal states — a late fault (likely during the outage this guards) no longer surfaces as an UnobservedTaskException; the client is disposed only on RanToCompletion. - HookSpool cap: count UTF-8 bytes (not chars) so the 1 MB cap holds for non-ASCII payloads (FileInfo.Length is byte-measured; char counts under-counted). Adds a regression test. - README: document durable lifecycle delivery (failed SessionStart/SessionEnd hooks spool to ~/.config/kcap/spool and replay on the next hook; reaped after 30 days) per CLAUDE.md. (qodo finding #1 'repo budget not enforced' was already resolved by the prior merge-fix commit.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- session-end/subagent-stop deferred repo enrichment is now budgeted (was unbudgeted), and ALL three enrichment awaits (session-end/subagent-stop/session-start) are bounded by the remaining hook budget via AwaitEnrichmentWithinBudget — falling back to the un-enriched body so the bounded POST/spool path is always reached before Claude kills the hook (repo info still arrives via the watcher's own detection). - RepoExclusion.IsExcludedAsync takes an optional budget, threaded into DetectRepositoryAsync, so the exclusion check's fallback repo detection can't delay the session-start watcher spawn (fail-open to capture on timeout; the per-cwd cache resolves excluded repos on subsequent sessions). - RepositoryDetection.RunCommandAsync now kills the child process tree on timeout/failure, so a slow git/gh probe doesn't outlive the hook and accumulate across repeated invocations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
A session that ended cleanly with
/exitcould stay Active in the Capacitor UI with noSessionEndedrecorded. Root cause (diagnosed on session9dc27753…): a brief server outage during a deploy window coincided with theSessionEndhook. Claude kills the hook at its ~15s timeout, so the in-hook POST (and even auth/client creation) could be cut off before the server returned — and any retry living inside the hook process can't outlast the outage. The lifecycle event was simply lost. The same fragility applied tosession-start(a failed start orphaned the whole session — no record and no transcript capture, since the watcher was only spawned after a successful POST).Approach
A vendor-neutral, on-disk hook spool. When a lifecycle POST fails transiently (unreachable / 5xx / 408 / 429 / timeout), the hook spools the payload locally and exits cleanly. On every subsequent Claude hook, a cross-session drainer replays pending entries (current-session-first). Server-side idempotency makes replay safe — no server changes.
The key to making it actually work: a process-start hook deadline. Everything before the spool can reach it — bootstrap
git, repo enrichment, auth/client creation, and the POST — is bounded by that deadline, and a minimal body is built (and the watcher spawned) before any of it, so the spool is always reached before Claude's kill. The POST is a single bounded attempt (PostOnceAsync); durable replay, not in-hook retry, provides reliability.What's in here
HookSpool— rotate-on-drain (atomic rename → private.drainingtemp; appends never collide with an in-flight drain), cross-sessionDrainAllAsync(currentSessionId, …)(current session first; recovered temps before the live file), tri-stateDrainOutcome(Delivered / Drop-on-permanent-4xx / TransientStop), 1 MB cap, 30-day reap.SpoolOnFailureevents; transforming, merging, concurrency-safe migration from the legacy dir).HookBudget) anchored inProgram.csbefore bootstrap; hook-pathgit/enrichment bounded by it.ClaudeHookCommand—HandleCoretest seam; bounded client creation that spools the lifecycle event if auth/creation hangs;session-end(bounded POST +ended_at+ spool,generate_whats_doneon success);session-start(watcher-first, single bounded POST, spool on failure); cross-session drain step (current-session-first,generate_whats_doneon replay).Recovery for already-stuck sessions
kcap importdoes not end an already-loaded session (its resume branch only backfills transcript). Replay theSessionEndhook instead (kcap hook --claudewith asession_endpayload) — which is exactly what this feature now automates.Testing
New unit coverage for
HookSpool,HookBudget,ClaudeHookCommand(5xx → spooled, hung server → spooled within budget, hung auth → spooled within budget, 4xx → not spooled, current-session ordering, cross-session drain), Cursor migration/regression; an integration outage → recovery round-trip plus thegenerate_whats_done:truereplay path. Full unit suite green except a pre-existingLocalPermissionBridgeTestsparallel-load port-conflict flake (passes 19/19 in isolation, unrelated). Integration 39/39. AOT-clean (no IL3050/IL2026).Design + plan:
docs/superpowers/specs/2026-06-26-durable-hook-spool-design.md,docs/superpowers/plans/2026-06-26-durable-hook-spool.md.Follow-ups (out of scope)
kcapagain.TokenStoreauth-timeout cleanup (bounding/auth/refreshitself; here it's covered by the hook's outer cap).🤖 Generated with Claude Code