feat(worker): queue interactive messages by default, interrupt opt-in

StreamingClaudeSession now buffers a mid-turn user message in a FIFO queue and
flushes one when the turn's result arrives (no implicit interrupt). InterruptAsync
only writes the control_request (no-op when idle); the resulting turn-end then
flushes any queued message. New InteractiveSessionService.InterruptAsync +
WorkerHub.InterruptInteractiveSession + IWorkerClient.InterruptInteractiveSessionAsync.
This commit is contained in:
Mika Kuns
2026-06-26 10:35:13 +02:00
parent 9c292e5080
commit bdda98eccd
8 changed files with 175 additions and 45 deletions

View File

@@ -11,7 +11,7 @@ public sealed class StreamingClaudeSession : ILiveSession
private readonly SemaphoreSlim _sendLock = new(1, 1);
private volatile bool _isTurnInFlight;
private TaskCompletionSource<bool> _turnTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Queue<string> _pending = new();
public bool IsTurnInFlight => _isTurnInFlight;
@@ -41,17 +41,32 @@ public sealed class StreamingClaudeSession : ILiveSession
try { await _onLine(line); }
catch (Exception ex) { _logger.LogWarning(ex, "onLine callback threw"); }
bool isResult;
try
{
using var doc = JsonDocument.Parse(line);
if (doc.RootElement.TryGetProperty("type", out var typeProp)
&& typeProp.GetString() == "result")
isResult = doc.RootElement.TryGetProperty("type", out var typeProp)
&& typeProp.GetString() == "result";
}
catch { isResult = false; }
if (!isResult) return;
// Turn ended — flush one queued message if available.
await _sendLock.WaitAsync();
try
{
_isTurnInFlight = false;
if (_pending.Count > 0)
{
_isTurnInFlight = false;
_turnTcs.TrySetResult(true);
var next = _pending.Dequeue();
await SendTurnAsync(next, CancellationToken.None);
}
}
catch { /* unparseable line — ignore */ }
finally
{
_sendLock.Release();
}
}
public async Task SendUserMessageAsync(string text, CancellationToken ct)
@@ -59,19 +74,14 @@ public sealed class StreamingClaudeSession : ILiveSession
await _sendLock.WaitAsync(ct);
try
{
if (_isTurnInFlight)
if (_isTurnInFlight || _pending.Count > 0)
{
await InterruptInternalAsync(ct);
// Wait for the current turn to end (interrupt-aborted result), with timeout.
var turnDone = _turnTcs.Task;
var timeout = Task.Delay(TimeSpan.FromSeconds(30), ct);
var winner = await Task.WhenAny(turnDone, timeout);
if (winner == timeout)
_logger.LogWarning("Timed out waiting for turn to end after interrupt; proceeding anyway.");
_pending.Enqueue(text);
}
else
{
await SendTurnAsync(text, ct);
}
await SendTurnAsync(text, ct);
}
finally
{
@@ -82,28 +92,29 @@ public sealed class StreamingClaudeSession : ILiveSession
public async Task InterruptAsync(CancellationToken ct)
{
await _sendLock.WaitAsync(ct);
try { await InterruptInternalAsync(ct); }
finally { _sendLock.Release(); }
}
private async Task InterruptInternalAsync(CancellationToken ct)
{
var requestId = Guid.NewGuid().ToString();
var payload = JsonSerializer.Serialize(new
try
{
type = "control_request",
request_id = requestId,
request = new { subtype = "interrupt" }
});
if (!_isTurnInFlight) return;
try { await _transport.WriteLineAsync(payload, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed to write interrupt control_request; degrading gracefully."); }
var requestId = Guid.NewGuid().ToString();
var payload = JsonSerializer.Serialize(new
{
type = "control_request",
request_id = requestId,
request = new { subtype = "interrupt" }
});
try { await _transport.WriteLineAsync(payload, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed to write interrupt control_request; degrading gracefully."); }
}
finally
{
_sendLock.Release();
}
}
private async Task SendTurnAsync(string text, CancellationToken ct)
{
// Reset the TCS for this new turn.
_turnTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_isTurnInFlight = true;
var payload = JsonSerializer.Serialize(new