Repository navigation
[AI-647] Close orphaned session when watcher's parent coding agent exits - #75
Conversation
When a user closes the terminal hosting their coding agent (or the agent
crashes / is force-killed) without firing the SessionEnd hook, the
session stays "Active" forever in the UI. The watcher already detects
this via its parent-PID monitor and self-terminates cleanly, but until
now it never told the server "the session is over", so no SessionEnded
event was written.
This change has the watcher take over the role of the missing
session-end hook: after the existing drain + WatcherDrainComplete +
SignalR dispose, POST /hooks/session-end/{vendor} with
reason="parent_exited", read back the generate_whats_done flag, and
spawn the what's-done generator when the server says so. SignalR is
disposed first so the server's StopAndDrainAsync skips its 10s drain
wait (no live watcher connection to signal).
Only fires for session watchers (agentId is null) that have crossed the
transcript threshold — short-lived sessions are left to the server's
existing trivial-session cleanup, and subagent watchers don't own a
session.
Mirrors the daemon's existing EndSessionForAgentAsync fallback for
hosted agents. Server-side idempotency (added in the paired
kapacitor-server PR) protects against the duplicate-POST race where
this fallback fires alongside a successful claude session-end hook.
Review Summary by QodoClose orphaned sessions when parent coding agent exits
WalkthroughsDescription• Watcher now POSTs /hooks/session-end/{vendor} when parent coding agent exits unexpectedly
• Prevents orphaned sessions from staying "Active" in UI indefinitely
• Reads generate_whats_done flag and spawns generator when needed
• Only fires for session watchers above transcript threshold; skips subagents
Diagramflowchart LR
A["Parent Process Dies"] -->|Detected by PID Monitor| B["Set parentExited Flag"]
B -->|After SignalR Dispose| C["POST /hooks/session-end/{vendor}"]
C -->|reason: parent_exited| D["Server Writes SessionEnded"]
D -->|Reads generate_whats_done| E["Spawn Generator if Needed"]
E -->|Session Closed| F["UI Updates Session Status"]
File Changes1. src/kapacitor/Commands/WatchCommand.cs
|
Code Review by Qodo
1.
|
Three Qodo findings on #75: - Bound the entire parent-exit POST in a 10s CancellationTokenSource so /auth/config discovery and PostWithRetryAsync can't stall watcher shutdown for ~130s when the server accepts TCP but never responds. Token is threaded into CreateAuthenticatedClientAsync, into PostWithRetryAsync (as both ct and timeout), and into the response body read. OperationCanceledException is treated as best-effort failure with a clear log line. - Validate vendor against a known whitelist (claude, codex) before interpolating into the URL path. Defence-in-depth against malformed --vendor values (e.g. "../admin") producing path traversal — though the CLI runs locally and the user would already have code execution, a verbatim path-segment concat is still a smell. - Promote parentExited from a bool local to an int + Interlocked / Volatile so the C# memory model formally guarantees the background-task write is observed on the main thread. Awaits already act as barriers in practice, but the explicit synchronization is cheap and removes the ambiguity. Adds a WireMock test for the unknown-vendor skip path. Did NOT change the URL form (vendor-routed even for claude) — server's route is /hooks/session-end/{vendor=claude} which matches both /session-end and /session-end/claude, and the paired server PR is what makes parent_exited semantically meaningful in the first place, so older servers aren't a concern here.
|
Thanks Qodo. Reviewed all four findings: #1 Shutdown POST can hang — accepted, fixed in e85ddbb. Real reliability concern. Wrapped the entire helper in a #2 Unvalidated vendor in URL — accepted, fixed in e85ddbb. Added a #3 Claude route compatibility — declined. The server route is #4 Awaits between write and read already act as memory barriers in practice, but Qodo's right that the C# memory model doesn't formally guarantee it. Promoted to |
Summary
SessionEnd, it now POSTs/hooks/session-end/{vendor}withreason: "parent_exited", reads backgenerate_whats_done, and spawns the what's-done generator when needed.agentId is null) that crossed the transcript threshold; subagent watchers and trivial sessions are skipped.StopAndDrainAsyncskips its 10s drain wait.Context
Closes the local-watcher equivalent of the gap the daemon already handles for hosted agents via
AgentOrchestrator.EndAgentSessionAsync. Without this, users who close their terminal mid-session see the session stuck "Active" in the UI even though the agent is clearly gone.Paired with kurrent-io/Kurrent.Capacitor#628 — the server change adds
SessionEndReason.ParentExitedplus idempotency guards onHandleSessionEndthat protect against the duplicate-POST race when this fallback fires alongside a successful claude session-end. Merge server PR first.Linear: AI-647
Test plan
WatcherParentExitPostTests(WireMock) — 3 new tests: payload shape, optional repository, vendor-specific route (claudevscodex).dotnet publish -c Release— clean, no IL3050/IL2026 AOT warnings.