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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user