fix(worker): harden CLI injection, stuck-Running, chain wedge, and Fail guard
1. ArgumentList (fix injection): ClaudeArgsBuilder.Build() now returns IReadOnlyList<string>; ClaudeProcess populates ProcessStartInfo.ArgumentList instead of Arguments, so values like system prompts are never shell-split. DailyPrepPrompt, RefinePrompt, and WeekReportService migrated similarly. All IClaudeProcess fakes updated. 2. ContinueAsync exception guard: wrap RunOnceAsync in try/catch matching the RunAsync pattern so an unexpected exception never leaves the task stuck in Running status. 3. Planning chain cascade: OnChildFinishedAsync now calls CancelAsync on the immediate blocked successor when a child fails or is cancelled, triggering a recursive cascade that clears the entire remaining chain instead of leaving it wedged. 4. FailAsync guard: restrict valid source states to Running and Queued; WaitingForReview -> Failed is now rejected, preventing an invalid transition that could corrupt the review workflow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,12 +27,12 @@ public sealed class ClaudeArgsBuilder
|
||||
required = new[] { "summary" },
|
||||
});
|
||||
|
||||
public string Build(ClaudeRunConfig config)
|
||||
public IReadOnlyList<string> Build(ClaudeRunConfig config)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"-p",
|
||||
"--output-format stream-json",
|
||||
"--output-format", "stream-json",
|
||||
"--verbose",
|
||||
};
|
||||
|
||||
@@ -40,50 +40,55 @@ public sealed class ClaudeArgsBuilder
|
||||
|| config.PermissionMode.Equals("bypassPermissions", StringComparison.OrdinalIgnoreCase)
|
||||
? "auto"
|
||||
: config.PermissionMode;
|
||||
args.Add($"--permission-mode {permissionMode}");
|
||||
args.Add("--permission-mode");
|
||||
args.Add(permissionMode);
|
||||
|
||||
if (config.Model is not null)
|
||||
args.Add($"--model {config.Model}");
|
||||
{
|
||||
args.Add("--model");
|
||||
args.Add(config.Model);
|
||||
}
|
||||
|
||||
if (config.MaxTurns is int turns && turns > 0)
|
||||
args.Add($"--max-turns {turns}");
|
||||
{
|
||||
args.Add("--max-turns");
|
||||
args.Add(turns.ToString());
|
||||
}
|
||||
|
||||
if (config.SystemPrompt is not null)
|
||||
args.Add($"--append-system-prompt {Escape(config.SystemPrompt)}");
|
||||
{
|
||||
args.Add("--append-system-prompt");
|
||||
args.Add(config.SystemPrompt);
|
||||
}
|
||||
|
||||
if (config.AgentPath is not null)
|
||||
{
|
||||
var agentJson = JsonSerializer.Serialize(new[] { new { file = config.AgentPath } });
|
||||
args.Add($"--agents {Escape(agentJson)}");
|
||||
args.Add("--agents");
|
||||
args.Add(agentJson);
|
||||
}
|
||||
|
||||
args.Add($"--json-schema {Escape(ResultSchema)}");
|
||||
args.Add("--json-schema");
|
||||
args.Add(ResultSchema);
|
||||
|
||||
if (config.McpConfigPath is not null)
|
||||
args.Add($"--mcp-config {Escape(config.McpConfigPath)}");
|
||||
{
|
||||
args.Add("--mcp-config");
|
||||
args.Add(config.McpConfigPath);
|
||||
}
|
||||
|
||||
if (config.AllowedTools is not null)
|
||||
args.Add($"--allowedTools {config.AllowedTools}");
|
||||
{
|
||||
args.Add("--allowedTools");
|
||||
args.Add(config.AllowedTools);
|
||||
}
|
||||
|
||||
if (config.ResumeSessionId is not null)
|
||||
args.Add($"--resume {config.ResumeSessionId}");
|
||||
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
if (value.Contains(' ') || value.Contains('"') || value.Contains('\'')
|
||||
|| value.Contains('\t') || value.Contains('\n') || value.Contains('\r'))
|
||||
{
|
||||
var escaped = value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"")
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\t", "\\t");
|
||||
return $"\"{escaped}\"";
|
||||
args.Add("--resume");
|
||||
args.Add(config.ResumeSessionId);
|
||||
}
|
||||
return value;
|
||||
|
||||
return args;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class ClaudeProcess : IClaudeProcess
|
||||
}
|
||||
|
||||
public async Task<RunResult> RunAsync(
|
||||
string arguments,
|
||||
IReadOnlyList<string> arguments,
|
||||
string prompt,
|
||||
string workingDirectory,
|
||||
Func<string, Task> onStdoutLine,
|
||||
@@ -27,7 +27,6 @@ public sealed class ClaudeProcess : IClaudeProcess
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _cfg.ClaudeBin,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = workingDirectory,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
@@ -37,6 +36,8 @@ public sealed class ClaudeProcess : IClaudeProcess
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
foreach (var arg in arguments)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace ClaudeDo.Worker.Runner;
|
||||
public interface IClaudeProcess
|
||||
{
|
||||
Task<RunResult> RunAsync(
|
||||
string arguments,
|
||||
IReadOnlyList<string> arguments,
|
||||
string prompt,
|
||||
string workingDirectory,
|
||||
Func<string, Task> onStdoutLine,
|
||||
|
||||
@@ -211,19 +211,32 @@ public sealed class TaskRunner
|
||||
await _state.StartRunningAsync(taskId, now, ct);
|
||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||
|
||||
var nextRunNumber = lastRun.RunNumber + 1;
|
||||
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
|
||||
|
||||
if (result.IsSuccess)
|
||||
try
|
||||
{
|
||||
await HandleSuccess(task, list, slot, wtCtx, result, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount);
|
||||
}
|
||||
var nextRunNumber = lastRun.RunNumber + 1;
|
||||
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await HandleSuccess(task, list, slot, wtCtx, result, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount);
|
||||
}
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Task {TaskId} was cancelled during continue", taskId);
|
||||
await MarkFailed(taskId, task.Title, slot, "Task cancelled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception continuing task {TaskId}", taskId);
|
||||
await MarkFailed(taskId, task.Title, slot, $"Unhandled error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RunDirResult(string? RunDir, WorktreeContext? WtCtx, string? FailureReason);
|
||||
|
||||
Reference in New Issue
Block a user