using ClaudeDo.Worker.Config; namespace ClaudeDo.Worker.Runner; // Stops interactive/streaming sessions that have gone idle. Interactive `claude` processes wait // on stdin and never exit on their own, and there is no client-disconnect teardown — so an // abandoned chat (UI closed, navigated away, crashed) keeps its claude.exe (+ conhost) alive for // the worker's entire lifetime. Under a long-running autostart worker these pile up (observed: // ~170 child processes). This sweep reaps the idle ones. public sealed class IdleSessionReaper : BackgroundService { private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(5); private readonly LiveSessionRegistry _registry; private readonly WorkerConfig _cfg; private readonly ILogger _logger; public IdleSessionReaper(LiveSessionRegistry registry, WorkerConfig cfg, ILogger logger) { _registry = registry; _cfg = cfg; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var idleTimeout = TimeSpan.FromMinutes(_cfg.InteractiveIdleTimeoutMinutes); if (idleTimeout <= TimeSpan.Zero) return; // reaper disabled using var timer = new PeriodicTimer(SweepInterval); while (await timer.WaitForNextTickAsync(stoppingToken)) { try { var reaped = await _registry.ReapIdleAsync(DateTime.UtcNow, idleTimeout); if (reaped.Count > 0) _logger.LogInformation( "Reaped {session_count} idle interactive session(s) after {idle_minutes} min: {task_ids}", reaped.Count, _cfg.InteractiveIdleTimeoutMinutes, string.Join(", ", reaped)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogWarning(ex, "Idle session reap sweep failed"); } } } }