feat(worker): add claude-cli runner, queue service, and hub api

Runner stack (non-worktree path): IClaudeProcess + ClaudeProcess spawning the
CLI with --output-format stream-json, prompt via stdin, parses the final
type:"result" line into RunResult. LogWriter appends ndjson to
~/.todo-app/logs/<taskId>.ndjson. TaskRunner orchestrates DB transitions
(MarkRunning -> MarkDone/Failed) and pushes TaskStarted/Message/Finished/
Updated via HubBroadcaster. Worktree-backed lists short-circuit with a
"Slice E" failure message until git support lands.

QueueService (BackgroundService) holds two in-memory slots (_queueSlot +
_overrideSlot) guarded by a lock. Uses PeriodicTimer + SemaphoreSlim wake
signal so WakeQueue() triggers an instant pickup. RunNow throws
InvalidOperationException when override busy; CancelTask cancels the linked
CTS which kills the child process tree.

WorkerHub extended with GetActive, RunNow (translated to HubException
variants), CancelTask, WakeQueue. HubBroadcaster exposes typed push methods.

Tests: 26 pass (12 new). QueueServiceTests cover override-busy,
schedule-filter, FIFO sequentiality, cancellation, plus a FakeClaudeProcess
that blocks on a TCS for deterministic slot-state assertions.
MessageParserTests cover result extraction + malformed/non-result lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-13 12:14:00 +02:00
co-authored by Claude Opus 4.6
parent 9f51ff0b17
commit e5038d7e16
14 changed files with 884 additions and 4 deletions
@@ -0,0 +1,96 @@
using System.Diagnostics;
using System.Text;
using ClaudeDo.Worker.Config;
namespace ClaudeDo.Worker.Runner;
public sealed class ClaudeProcess : IClaudeProcess
{
private readonly WorkerConfig _cfg;
private readonly ILogger<ClaudeProcess> _logger;
public ClaudeProcess(WorkerConfig cfg, ILogger<ClaudeProcess> logger)
{
_cfg = cfg;
_logger = logger;
}
public async Task<RunResult> RunAsync(
string prompt,
string workingDirectory,
string logPath,
string taskId,
Func<string, Task> onStdoutLine,
CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = _cfg.ClaudeBin,
Arguments = "-p --output-format stream-json --verbose --dangerously-skip-permissions",
WorkingDirectory = workingDirectory,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
using var process = new Process { StartInfo = psi };
process.Start();
// Write prompt to stdin, then close.
await process.StandardInput.WriteAsync(prompt);
process.StandardInput.Close();
string? resultMarkdown = null;
var lastStderr = new StringBuilder();
// Register cancellation to kill the process tree.
await using var ctr = ct.Register(() =>
{
try { process.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
// Read stdout and stderr concurrently.
var stdoutTask = Task.Run(async () =>
{
while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
{
if (string.IsNullOrEmpty(line)) continue;
await onStdoutLine(line);
if (MessageParser.TryExtractResult(line, out var res))
resultMarkdown = res;
}
}, ct);
var stderrTask = Task.Run(async () =>
{
while (await process.StandardError.ReadLineAsync(ct) is { } line)
{
if (string.IsNullOrEmpty(line)) continue;
lastStderr.AppendLine(line);
await onStdoutLine($"[stderr] {line}");
}
}, ct);
await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(ct);
var exitCode = process.ExitCode;
if (exitCode == 0 && resultMarkdown is not null)
{
return new RunResult { ExitCode = exitCode, ResultMarkdown = resultMarkdown };
}
var error = lastStderr.Length > 0
? lastStderr.ToString().Trim()
: $"Claude exited with code {exitCode} and no result.";
return new RunResult { ExitCode = exitCode, ErrorMarkdown = error };
}
}