Files
ClaudeDo/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs
T

158 lines
5.8 KiB
C#

using System.Diagnostics;
using System.Text;
using ClaudeDo.Data.Environment;
using ClaudeDo.Worker.Config;
namespace ClaudeDo.Worker.Runner;
public sealed class ClaudeProcess : IClaudeProcess
{
public const string NoResultPrefix = "Claude exited with code";
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(
IReadOnlyList<string> arguments,
string prompt,
string workingDirectory,
Func<string, Task> onStdoutLine,
CancellationToken ct)
{
var resolved = ExecutableResolver.Resolve(_cfg.ClaudeBin)
?? throw new InvalidOperationException($"'{_cfg.ClaudeBin}' not found on PATH.");
var psi = new ProcessStartInfo
{
WorkingDirectory = workingDirectory,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
// A shim (.cmd/.bat) can't be launched directly with UseShellExecute=false;
// route it through cmd.exe /c so the real interpreter starts the target.
if (resolved.IsShim)
{
psi.FileName = "cmd.exe";
psi.ArgumentList.Add("/c");
psi.ArgumentList.Add(resolved.Path);
}
else
{
psi.FileName = resolved.Path;
}
foreach (var arg in arguments)
psi.ArgumentList.Add(arg);
// Claude Code caps HTTP MCP tool calls at 60 s unless MCP_TOOL_TIMEOUT is raised.
// wait_for_task_change blocks up to TaskWaitMcpTools.MaxTimeoutSeconds (900 s) and the
// in-task AskUser tool blocks up to 3 min waiting for the user, so lift the cap well
// past the longer of the two (with margin) or that wait would be killed early. Harmless
// for every other tool. Shares the value with InteractiveLaunchSpecService.McpToolTimeoutMs.
psi.Environment["MCP_TOOL_TIMEOUT"] = InteractiveLaunchSpecService.McpToolTimeoutMs;
using var process = new Process { StartInfo = psi };
process.Start();
ProcessJobObject.Assign(process, _logger);
await process.StandardInput.WriteAsync(prompt);
process.StandardInput.Close();
var analyzer = new StreamAnalyzer();
var lastStderr = new StringBuilder();
// On cancellation: kill the tree. Killing closes the redirected pipes,
// which unblocks the ReadLineAsync loops below (which run without ct
// so they reliably drain instead of hanging on cancellation).
await using var ctr = ct.Register(() =>
{
try { process.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
var stdoutTask = Task.Run(async () =>
{
while (await process.StandardOutput.ReadLineAsync() is { } line)
{
if (string.IsNullOrEmpty(line)) continue;
await onStdoutLine(line);
analyzer.ProcessLine(line);
}
});
var stderrTask = Task.Run(async () =>
{
while (await process.StandardError.ReadLineAsync() is { } line)
{
if (string.IsNullOrEmpty(line)) continue;
lastStderr.AppendLine(line);
await onStdoutLine($"[stderr] {line}");
}
});
await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(CancellationToken.None);
// If we were asked to cancel, surface that to the caller now that
// the process is fully reaped.
ct.ThrowIfCancellationRequested();
var exitCode = process.ExitCode;
var streamResult = analyzer.GetResult();
if (exitCode == 0 && streamResult.ResultMarkdown is not null)
{
return new RunResult
{
ExitCode = exitCode,
ResultMarkdown = streamResult.ResultMarkdown,
StructuredOutputJson = streamResult.StructuredOutputJson,
SessionId = streamResult.SessionId,
TurnCount = streamResult.TurnCount,
TokensIn = streamResult.TokensIn,
TokensOut = streamResult.TokensOut,
Blocks = streamResult.Blocks,
ResultSubtype = streamResult.ResultSubtype,
TerminalReason = streamResult.TerminalReason,
Errors = streamResult.Errors,
PermissionDenials = streamResult.PermissionDenials,
};
}
var error = lastStderr.Length > 0
? lastStderr.ToString().Trim()
: $"{NoResultPrefix} {exitCode} and no result.";
return new RunResult
{
ExitCode = exitCode,
// Kept even on failure: a terminal reason like api_error often carries the
// provider's own message (e.g. session-limit + reset time) in this field,
// with nothing useful on stderr.
ResultMarkdown = streamResult.ResultMarkdown,
ErrorMarkdown = error,
SessionId = streamResult.SessionId,
TurnCount = streamResult.TurnCount,
TokensIn = streamResult.TokensIn,
TokensOut = streamResult.TokensOut,
Blocks = streamResult.Blocks,
ResultSubtype = streamResult.ResultSubtype,
TerminalReason = streamResult.TerminalReason,
Errors = streamResult.Errors,
PermissionDenials = streamResult.PermissionDenials,
};
}
}