fix(worker): reap idle interactive sessions so they don't pile up
Interactive/streaming sessions are persistent claude.exe processes that wait on stdin and never exit on their own. The only teardown was an explicit StopInteractiveSession from the UI — there is no client-disconnect or shutdown sweep — so an abandoned chat (UI closed, navigated away, crashed) kept its claude.exe (+ conhost) alive for the worker's whole lifetime. Under a long-running autostart worker these accumulate to dozens of orphaned child processes. LiveSessionRegistry now tracks per-session activity (Touch on every output line and user action) and exposes ReapIdleAsync, which stops sessions idle past a timeout while skipping any with a turn in flight. IdleSessionReaper (BackgroundService) sweeps every 5 min; idle timeout defaults to 30 min, configurable via interactive_idle_timeout_minutes (0 disables).
This commit is contained in:
@@ -8,7 +8,7 @@ public sealed class LiveSessionRegistryTests
|
||||
private sealed class FakeLiveSession : ILiveSession
|
||||
{
|
||||
public bool StopCalled { get; private set; }
|
||||
public bool IsTurnInFlight => false;
|
||||
public bool IsTurnInFlight { get; set; }
|
||||
|
||||
public Task SendUserMessageAsync(string text, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task RemoveQueuedAsync(string text, CancellationToken ct) => Task.CompletedTask;
|
||||
@@ -90,4 +90,56 @@ public sealed class LiveSessionRegistryTests
|
||||
var registry = new LiveSessionRegistry();
|
||||
await registry.StopAsync("no-such-task"); // should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReapIdleAsync_StopsAndRemovesIdleSession()
|
||||
{
|
||||
var registry = new LiveSessionRegistry();
|
||||
var session = new FakeLiveSession();
|
||||
registry.Register("task-1", session);
|
||||
|
||||
// Sweep "now" is an hour past registration, well beyond the 30-min idle window.
|
||||
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow.AddMinutes(60), TimeSpan.FromMinutes(30));
|
||||
|
||||
Assert.Contains("task-1", reaped);
|
||||
Assert.True(session.StopCalled);
|
||||
Assert.False(registry.TryGet("task-1", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReapIdleAsync_KeepsRecentlyActiveSession()
|
||||
{
|
||||
var registry = new LiveSessionRegistry();
|
||||
var session = new FakeLiveSession();
|
||||
registry.Register("task-1", session);
|
||||
|
||||
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow, TimeSpan.FromMinutes(30));
|
||||
|
||||
Assert.Empty(reaped);
|
||||
Assert.False(session.StopCalled);
|
||||
Assert.True(registry.TryGet("task-1", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReapIdleAsync_SkipsSessionWithTurnInFlight()
|
||||
{
|
||||
var registry = new LiveSessionRegistry();
|
||||
var session = new FakeLiveSession { IsTurnInFlight = true };
|
||||
registry.Register("task-1", session);
|
||||
|
||||
// Idle long enough to reap, but a turn is in flight → must be left alone.
|
||||
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow.AddMinutes(60), TimeSpan.FromMinutes(30));
|
||||
|
||||
Assert.Empty(reaped);
|
||||
Assert.False(session.StopCalled);
|
||||
Assert.True(registry.TryGet("task-1", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReapIdleAsync_NoSessions_ReturnsEmpty()
|
||||
{
|
||||
var registry = new LiveSessionRegistry();
|
||||
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow, TimeSpan.FromMinutes(30));
|
||||
Assert.Empty(reaped);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user