Repository navigation
Fix hosted/recorded sessions getting stuck "active" (orphaned watcher + daemon session-end) - #154
Conversation
The `kcap watch` sidecar's parent-exit watchdog self-terminates and POSTs session-end when the coding agent dies, so an abruptly-killed agent (crash, SIGKILL, daemon death) doesn't leave the watcher orphaned and the session stuck "active". For Claude it silently never started: GetCodingAgentPid() used getpgrp(), which resolves the durable agent for Codex but a transient hook process group for Claude — already dead by the time the watcher's IsProcessAlive guard ran, so monitoring was skipped with no log line. Resolve the agent PID by walking the ppid ancestry and matching the agent by name (claude/codex), robust to the differing process-group/launcher topologies of each vendor. Add per-PID introspection (macOS proc_pidinfo, Linux /proc/<pid>/stat). Harden the watcher startup guard (DecideParentWatchdog) so a dead-at-startup parent PID is logged loudly instead of silently skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A hosted agent's AgentSession could stay "active" if the daemon's EndAgentSession invoke hit a transient WebSocket drop — it failed fast and was only logged. Route EndAgentSessionAsync through the same ConnectionRetry helper RequestPermission uses, so it rides through a reconnect. Because the retry can block while the connection recovers, a user-initiated stop must not wait on it: HandleStopAgent now terminates the process and lets the post-exit FinalizeAgentRunAsync backstop end the session (with retry, using the "agent_stopped" reason), so teardown isn't delayed. Server-side end is idempotent, so it's a no-op if claude already fired its own session-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The client-side fixes (watcher watchdog, daemon EndAgentSession) can't cover a daemon/host that dies hard — by definition the cleanup code is dead. Document the server-side backstop (daemon-disconnect and watcher-disconnect reconcile with a grace window) for the kcap-server repo, plus the open question of why a replacement daemon masked the old one's disconnect in the observed incident. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review by Qodo
1.
|
PR Summary by QodoPrevent hosted sessions stuck "active" (watcher PID resolution + EndAgentSession retry) WalkthroughsDescription• Fix watcher parent-PID watchdog for Claude by resolving the durable agent PID via ancestry. • Retry daemon EndAgentSession across transient SignalR reconnects without blocking stop termination. • Add server-side reconciliation spec for sessions orphaned by hard daemon/host death. Diagramgraph TD
WM["WatcherManager"] --> PH["ProcessHelpers"] --> WC["WatchCommand"] --> S{{"kcap server"}}
AO["AgentOrchestrator"] --> SC["ServerConnection"] --> CR["ConnectionRetry"] --> S
subgraph Legend
direction LR
_mod["Module"] ~~~ _svc["Service"] ~~~ _ext{{"External"}}
end
High-Level AssessmentThe following are alternative approaches to this PR: 1. Pass durable agent PID explicitly (env var/arg)
2. Shell out to ps/pgrep for (ppid, comm) lookup
3. Fire-and-forget EndAgentSession on stop (don’t await)
Recommendation: The PR’s approach is the best tradeoff: resolving the agent PID by walking PPID ancestry is robust to vendor-specific process-group topology (the root cause) while remaining unit-testable via an injected lookup. Reusing the existing ConnectionRetry helper for EndAgentSession keeps SignalR semantics consistent with RequestPermission and, by moving session-end to the post-exit finalize path, preserves fast user-initiated stop behavior without sacrificing reliability. The alternatives either require broader integration changes (explicit PID) or add new operational brittleness (ps parsing / fire-and-forget). File ChangesBug fix (5)
Tests (4)
Documentation (1)
|
The ancestry-walk resolver was gated to claude/codex, but CopilotHookCommand also spawns `kcap watch --vendor copilot`, leaving Copilot on the legacy getpgrp() heuristic. Broaden the gate to any non-empty vendor: the walk matches by the vendor's process name and falls back to the legacy heuristic when no match is found, so it can't regress a vendor whose process isn't named after it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Qodo review: FinalizeAgentRunAsync awaited EndAgentSessionAsync (now unbounded across SignalR reconnects) before CleanupAgentAsync, so a prolonged outage could stall worktree/process cleanup and pin the agent in _agents until daemon shutdown. Bound the wait to EndAgentSessionBudget (30s); on timeout, proceed to cleanup while the retry continues in the background (observed so it isn't an unobserved task exception). Cleanup now runs unconditionally. A genuinely long outage falls back to server-side daemon-disconnect reconcile (see the reconcile spec). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Problem
A daemon-hosted Claude session (
9687912e…) showed active forever on the server after its hostedclaudeprocess had already died. Forensics: theclaudePID was dead, but its twokcap watchsidecars were orphaned but still alive, holding live SignalR connections — and the hosting daemon had been replaced without sendingEndAgentSession. So "active" was held open by orphaned watcher connections that never POSTed session-end, plus a missingEndAgentSession.Root cause
The
kcap watchparent-exit watchdog (self-terminate + POST session-end when the agent dies) silently never started for Claude.GetCodingAgentPid()usedgetpgrp(), which resolves the durable agent for Codex but a transient hook process group for Claude — already dead by the time the watcher'sIsProcessAlivestartup guard ran, so monitoring was skipped with no log line. Quantified across~/.config/kcap/logs: every recent Claude watcher had 0Monitoring parent pidlines; only Codex had them.Changes
proc_pidinfo, Linux/proc/<pid>/stat). Hardens the watcher startup guard so a dead-at-startup parent PID is logged loudly instead of silently skipped.EndAgentSessionresilience — routesEndAgentSessionAsyncthrough the existingConnectionRetryhelper (same oneRequestPermissionuses) so it survives a transient SignalR drop.HandleStopAgentnow terminates the process first and lets the post-exitFinalizeAgentRunAsyncbackstop end the session (with retry), so a stop is never delayed by a reconnecting end-session call.Testing
GetProcessInfo, vendor-awareGetCodingAgentPid,DecideParentWatchdog, and a daemon test proving a blockedEndAgentSessionno longer delays stop termination.ConnectionRetry/RegistrationGate) is already onmain(Retry hosted-agent permission requests across SignalR reconnects #152); this builds on it.Not covered here: integration tests needing a live server; the server-side reconcile (separate repo).
🤖 Generated with Claude Code