Files
ClaudeDo/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs
T
mika kuns 2ebdadff08 fix(worker): surface the real reason a Claude run failed instead of a generic exit-code message
StreamAnalyzer now reads subtype/terminal_reason/errors from the CLI's result
event, and TaskRunner builds a specific ErrorMarkdown from them: max_turns names
the turn budget and points at set_task_config + requeue, api_error passes
through the provider's own message (which carries the reset time), and any
other terminal_reason is appended to the previous generic text instead of
staying invisible. Falls through to the old "exited with code N and no result"
text when there's no terminal_reason at all (a real crash).
2026-08-05 15:55:09 +02:00

188 lines
7.5 KiB
C#

using System.Linq;
using System.Text.Json;
namespace ClaudeDo.Worker.Runner;
public sealed class StreamResult
{
public string? ResultMarkdown { get; set; }
public string? StructuredOutputJson { get; set; }
public string? SessionId { get; set; }
public int TurnCount { get; set; }
public int TokensIn { get; set; }
public int TokensOut { get; set; }
public int ApiRetryCount { get; set; }
public IReadOnlyList<string> Blocks { get; set; } = Array.Empty<string>();
public string? ResultSubtype { get; set; }
public string? TerminalReason { get; set; }
public IReadOnlyList<string> Errors { get; set; } = Array.Empty<string>();
}
public sealed class StreamAnalyzer
{
private string? _resultMarkdown;
private string? _structuredOutputJson;
private string? _sessionId;
private int _turnCount;
private int _tokensIn;
private int _tokensOut;
private int _apiRetryCount;
private readonly List<string> _blocks = new();
private string? _resultSubtype;
private string? _terminalReason;
private readonly List<string> _errors = new();
private const string BlockedPrefix = "CLAUDEDO_BLOCKED:";
public void ProcessLine(string ndjsonLine)
{
if (string.IsNullOrWhiteSpace(ndjsonLine)) return;
try
{
using var doc = JsonDocument.Parse(ndjsonLine);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeProp)) return;
var type = typeProp.GetString();
switch (type)
{
case "result":
if (root.TryGetProperty("result", out var resultProp))
_resultMarkdown = StripAndCollect(resultProp.GetString());
if (root.TryGetProperty("structured_output", out var structuredProp))
_structuredOutputJson = structuredProp.ToString();
if (root.TryGetProperty("session_id", out var sessionProp))
_sessionId = sessionProp.GetString();
if (root.TryGetProperty("subtype", out var resultSubtypeProp))
_resultSubtype = resultSubtypeProp.GetString();
if (root.TryGetProperty("terminal_reason", out var terminalReasonProp))
_terminalReason = terminalReasonProp.GetString();
if (root.TryGetProperty("errors", out var errorsProp) && errorsProp.ValueKind == JsonValueKind.Array)
{
foreach (var errorItem in errorsProp.EnumerateArray())
if (errorItem.ValueKind == JsonValueKind.String && errorItem.GetString() is { } errorText
&& !string.IsNullOrEmpty(errorText))
_errors.Add(errorText);
}
// Authoritative token totals live on the result event.
if (root.TryGetProperty("usage", out var resultUsage))
{
if (resultUsage.TryGetProperty("input_tokens", out var inp))
_tokensIn = inp.GetInt32();
if (resultUsage.TryGetProperty("output_tokens", out var outp))
_tokensOut = outp.GetInt32();
}
break;
case "assistant":
_turnCount++;
CollectFromAssistant(root);
break;
case "system":
if (root.TryGetProperty("subtype", out var subtypeProp) &&
subtypeProp.GetString() == "api_retry")
_apiRetryCount++;
break;
case "stream_event":
TryAccumulateUsage(root);
break;
}
}
catch (JsonException) { /* Malformed JSON — skip */ }
}
public StreamResult GetResult() => new()
{
ResultMarkdown = FallbackResult(),
StructuredOutputJson = _structuredOutputJson,
SessionId = _sessionId,
TurnCount = _turnCount,
TokensIn = _tokensIn,
TokensOut = _tokensOut,
ApiRetryCount = _apiRetryCount,
Blocks = _blocks.Distinct().ToList(),
ResultSubtype = _resultSubtype,
TerminalReason = _terminalReason,
Errors = _errors,
};
private string? FallbackResult()
{
// A --json-schema run sometimes echoes the structured JSON as the result text; unwrap
// that to its summary. A plain markdown result is kept verbatim (even when a separate
// structured_output is also present). Only when there is no result text do we fall back
// to the structured summary, then the raw JSON.
if (SummaryFromJson(_resultMarkdown) is { } fromResult) return fromResult;
if (!string.IsNullOrEmpty(_resultMarkdown)) return _resultMarkdown;
if (SummaryFromJson(_structuredOutputJson) is { } fromStructured) return fromStructured;
return _structuredOutputJson;
}
/// <summary>Extracts a non-empty string <c>summary</c> from a JSON object, or null when
/// the input isn't such an object (a plain markdown result parses to null and is kept).</summary>
private static string? SummaryFromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("summary", out var s) &&
s.ValueKind == JsonValueKind.String)
{
var text = s.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
catch { }
return null;
}
private void CollectFromAssistant(JsonElement root)
{
if (!root.TryGetProperty("message", out var msg)) return;
if (msg.ValueKind != JsonValueKind.Object) return;
if (!msg.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) return;
foreach (var block in content.EnumerateArray())
if (block.TryGetProperty("type", out var t) && t.GetString() == "text"
&& block.TryGetProperty("text", out var txt))
ScanForBlocks(txt.GetString());
}
private void ScanForBlocks(string? text)
{
if (string.IsNullOrEmpty(text)) return;
foreach (var line in text.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith(BlockedPrefix, StringComparison.Ordinal))
_blocks.Add(trimmed[BlockedPrefix.Length..].Trim());
}
}
private string? StripAndCollect(string? text)
{
if (string.IsNullOrEmpty(text)) return text;
ScanForBlocks(text);
var kept = text.Split('\n')
.Where(l => !l.Trim().StartsWith(BlockedPrefix, StringComparison.Ordinal));
return string.Join('\n', kept).Trim();
}
private void TryAccumulateUsage(JsonElement root)
{
if (!root.TryGetProperty("event", out var eventProp)) return;
if (eventProp.TryGetProperty("message", out var msgProp) &&
msgProp.TryGetProperty("usage", out var usageProp))
{
if (usageProp.TryGetProperty("input_tokens", out var inp))
_tokensIn += inp.GetInt32();
if (usageProp.TryGetProperty("output_tokens", out var outp))
_tokensOut += outp.GetInt32();
}
}
}