fix(worker): reap idle interactive sessions so they don't pile up
All checks were successful
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 51s

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:
Mika Kuns
2026-06-26 14:34:44 +02:00
parent faf6104645
commit 711374e858
6 changed files with 170 additions and 9 deletions

View File

@@ -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);
}
}