Files
ClaudeDo/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs
T
mika kuns e88f9d01e6 fix(worker): resolve claude CLI shims (.cmd/.bat) not just .exe on PATH
UseShellExecute=false only appends .exe when searching PATH, so an
npm-installed claude.cmd was never found even though it works from a shell.
Adds a shared ExecutableResolver in ClaudeDo.Data (PATH/PATHEXT aware, with
known npm/claude install-dir fallbacks) and wires it into ClaudeCliPreflight
and ClaudeProcess; shims are launched via cmd.exe /c.
2026-08-05 19:08:20 +02:00

154 lines
5.4 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.
// The in-task AskUser tool blocks up to 3 min waiting for the user, so lift the cap
// (with margin) or that wait would be killed early. Harmless for every other tool.
psi.Environment["MCP_TOOL_TIMEOUT"] = "200000";
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,
};
}
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,
};
}
}