Merge branch 'claudedo/0b2fbb48d44c41558c21d3464c0bd5c2'

This commit is contained in:
mika kuns
2026-08-05 12:11:55 +02:00
30 changed files with 1473 additions and 130 deletions
+20 -2
View File
@@ -8,7 +8,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner (IVerifyCommandRunner — spawns a list's optional post-merge verify command via `cmd.exe /c`), ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
@@ -102,7 +102,25 @@ that has children, drives `PlanningMergeOrchestrator` (merges the parent worktre
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
approve is the single review+merge action. Review transitions live in `TaskStateService`
approve is the single review+merge action.
**Post-merge verify gate.** A list can set `ListConfigEntity.VerifyCommand` (List Settings
modal → Verification). Null/blank (the default) = no gate, behavior is bit-identical to
before this existed. When set, `TaskMergeService` runs it via `VerifyCommandRunner`
(`cmd.exe /c <command>`, 10-minute fixed timeout, output tail-captured) in `list.WorkingDir`
right after a successful `MergeNoFfAsync`/`ContinueMergeAsync` and worktree cleanup, but
*before* the task is allowed to reach `Done`. Exit 0 → unchanged flow (worktree marked
`Merged`, task `Done` if it was `WaitingForReview`). Non-zero exit or a timeout → the git
merge is deliberately left in place (no auto-revert — that's a separate, unbuilt feature),
the worktree is still marked `Merged` (it's already gone from disk when `removeWorktree`
was requested), but the task stays out of `Done` and `MergeResult.Status` comes back
`TaskMergeService.StatusVerifyFailed` (`"verify_failed"`) with an output excerpt in
`ErrorMessage` — this flows through `MergeResultDto` (hub) and `ReviewTaskResult`
(`review_task` MCP tool) unchanged, since both already treat any non-`blocked`/`conflict`
status generically. A process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
`list.WorkingDir` serializes `MergeAsync`/`ContinueMergeAsync` (git ops + verify) per repo,
so a verify run can't be interrupted by a second merge landing in the same working dir
mid-build. Review transitions live in `TaskStateService`
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
+6 -4
View File
@@ -79,9 +79,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
public record SeedResultDto(int Copied, int Skipped);
public record OnlineInboxStateDto(
@@ -521,8 +521,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var systemPrompt = dto.SystemPrompt.NullIfBlank();
var agentPath = dto.AgentPath.NullIfBlank();
var sessionSkills = SkillsToJson(dto.SessionSkills);
var verifyCommand = dto.VerifyCommand.NullIfBlank();
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null)
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null)
{
await repo.DeleteConfigAsync(dto.ListId);
}
@@ -536,6 +537,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
AgentPath = agentPath,
MaxTurns = dto.MaxTurns,
SessionSkills = sessionSkills,
VerifyCommand = verifyCommand,
});
}
@@ -548,7 +550,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId);
if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills));
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand);
}
public async Task SetTaskStatus(string taskId, string status)
@@ -0,0 +1,8 @@
namespace ClaudeDo.Worker.Lifecycle;
public sealed record VerifyCommandResult(int ExitCode, bool TimedOut, string Output);
public interface IVerifyCommandRunner
{
Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct);
}
+201 -110
View File
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -40,10 +41,11 @@ public sealed record RevertResult(
public sealed class TaskMergeService
{
public const string StatusMerged = "merged";
public const string StatusConflict = "conflict";
public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted";
public const string StatusMerged = "merged";
public const string StatusConflict = "conflict";
public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted";
public const string StatusVerifyFailed = "verify_failed";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
@@ -52,10 +54,25 @@ public sealed class TaskMergeService
public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable";
// The verify command is a trusted, list-owner-configured build/test invocation (not
// per-request user input), so a generous fixed timeout is enough — no need for a
// per-list configurable value on top of what the spec calls for.
private static readonly TimeSpan VerifyTimeout = TimeSpan.FromMinutes(10);
// Serializes merge (+ verify) against the same repo working dir: a verify command running
// in list.WorkingDir must not see a second merge land mid-build. Keyed by working dir since
// TaskMergeService is a process-wide singleton and merges across different lists are independent.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> MergeGates =
new(StringComparer.OrdinalIgnoreCase);
private static SemaphoreSlim GetMergeGate(string workingDir) =>
MergeGates.GetOrAdd(workingDir, static _ => new SemaphoreSlim(1, 1));
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly HubBroadcaster _broadcaster;
private readonly ITaskStateService _state;
private readonly IVerifyCommandRunner _verify;
private readonly ILogger<TaskMergeService> _logger;
public TaskMergeService(
@@ -63,25 +80,67 @@ public sealed class TaskMergeService
GitService git,
HubBroadcaster broadcaster,
ITaskStateService state,
IVerifyCommandRunner verify,
ILogger<TaskMergeService> logger)
{
_dbFactory = dbFactory;
_git = git;
_broadcaster = broadcaster;
_state = state;
_verify = verify;
_logger = logger;
}
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree)> LoadMergeContextAsync(
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
var listRepo = new ListRepository(ctx);
var list = await listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
return (task, list, wt);
var config = await listRepo.GetConfigAsync(task.ListId, ct);
return (task, list, wt, config?.VerifyCommand);
}
/// <summary>
/// Runs the list's configured verify command (if any) in <paramref name="workingDir"/> after
/// a successful merge. Returns null when there is nothing to gate on (identical to today's
/// behavior); otherwise returns the terminal <see cref="MergeResult"/> to report instead of
/// merged (the merge itself is left in place either way — see the design notes in Worker's
/// CLAUDE.md — only the Done transition is withheld).
/// </summary>
private async Task<MergeResult?> RunVerifyGateAsync(
string? verifyCommand, string workingDir, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(verifyCommand)) return null;
VerifyCommandResult result;
try
{
result = await _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "verify command failed to start: {Command}", verifyCommand);
return new MergeResult(StatusVerifyFailed, Array.Empty<string>(),
$"verify command failed to start: {ex.Message}");
}
if (!result.TimedOut && result.ExitCode == 0) return null;
var reason = result.TimedOut
? $"verify command timed out after {VerifyTimeout.TotalMinutes:0} min: {verifyCommand}"
: $"verify command failed (exit {result.ExitCode}): {verifyCommand}";
return new MergeResult(StatusVerifyFailed, Array.Empty<string>(), $"{reason}\n{TailOutput(result.Output)}");
}
private static string TailOutput(string output, int maxChars = 4000)
{
var trimmed = output.Trim();
return trimmed.Length <= maxChars ? trimmed : trimmed[^maxChars..];
}
private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct)
@@ -110,7 +169,7 @@ public sealed class TaskMergeService
bool leaveConflictsInTree,
CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
if (task.Status == TaskStatus.Running)
return Blocked("task is running");
@@ -120,81 +179,97 @@ public sealed class TaskMergeService
return Blocked($"worktree state is {wt.State}");
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return Blocked("list has no working directory");
if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
return Blocked("working directory is not a git repository");
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
var gate = GetMergeGate(list.WorkingDir);
await gate.WaitAsync(ct);
try
{
try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); }
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
return Blocked("working directory is not a git repository");
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
if (exitCode != 0)
{
List<string> files;
try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
catch { files = new(); }
if (leaveConflictsInTree && files.Count > 0)
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
{
try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); }
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
if (exitCode != 0)
{
List<string> files;
try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
catch { files = new(); }
if (leaveConflictsInTree && files.Count > 0)
{
return new MergeResult(StatusConflict, files, null);
}
// If abort fails the repo is left mid-merge; the caller must resolve manually.
// Return Blocked (not conflict) so the UI does not offer a stale conflict list.
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
catch (Exception ex)
{
_logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge");
return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually");
}
if (files.Count == 0)
{
// Non-conflict failure (e.g. unrelated histories).
return new MergeResult(StatusBlocked, Array.Empty<string>(), $"merge failed: {stderr}");
}
return new MergeResult(StatusConflict, files, null);
}
// If abort fails the repo is left mid-merge; the caller must resolve manually.
// Return Blocked (not conflict) so the UI does not offer a stale conflict list.
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
catch (Exception ex)
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
string? cleanupWarning = null;
if (removeWorktree)
{
_logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge");
return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually");
}
if (files.Count == 0)
{
// Non-conflict failure (e.g. unrelated histories).
return new MergeResult(StatusBlocked, Array.Empty<string>(), $"merge failed: {stderr}");
}
return new MergeResult(StatusConflict, files, null);
}
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
string? cleanupWarning = null;
if (removeWorktree)
{
try
{
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct);
try { await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct); }
try
{
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct);
try { await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct); }
catch (Exception ex)
{
_logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
_logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
cleanupWarning = $"worktree remove failed: {ex.Message}";
}
}
catch (Exception ex)
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct);
if (verifyFailure is not null)
{
_logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
cleanupWarning = $"worktree remove failed: {ex.Message}";
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
taskId, wt.BranchName, targetBranch, removeWorktree);
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
}
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
taskId, wt.BranchName, targetBranch, removeWorktree);
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
finally { gate.Release(); }
}
public Task<MergeResult> MergeAsync(
@@ -207,54 +282,70 @@ public sealed class TaskMergeService
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
if (wt is null) return Blocked("task has no worktree");
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory");
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("repo is not mid-merge");
// Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of
// its content, so an unresolved file with markers still in it would otherwise get
// staged (and committed) as-is. Check text content for markers first; binary files
// can't carry markers, so they're left to the post-stage index check below.
var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var stillConflicted = new List<string>();
foreach (var path in unresolved)
var gate = GetMergeGate(list.WorkingDir);
await gate.WaitAsync(ct);
try
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { continue; }
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("repo is not mid-merge");
if (!LooksBinary(text) && ConflictMarkerParser.HasConflicts(text))
stillConflicted.Add(path);
// Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of
// its content, so an unresolved file with markers still in it would otherwise get
// staged (and committed) as-is. Check text content for markers first; binary files
// can't carry markers, so they're left to the post-stage index check below.
var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var stillConflicted = new List<string>();
foreach (var path in unresolved)
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { continue; }
if (!LooksBinary(text) && ConflictMarkerParser.HasConflicts(text))
stillConflicted.Add(path);
}
if (stillConflicted.Count > 0)
return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");
await _git.AddAllAsync(list.WorkingDir, ct);
var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
if (remaining.Count > 0)
return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved");
try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct);
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
}
if (stillConflicted.Count > 0)
return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");
await _git.AddAllAsync(list.WorkingDir, ct);
var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
if (remaining.Count > 0)
return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved");
try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
finally { gate.Release(); }
}
public async Task<MergeResult> AbortMergeAsync(string taskId, CancellationToken ct)
{
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (_, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
if (wt is null) return Blocked("task has no worktree");
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
@@ -282,7 +373,7 @@ public sealed class TaskMergeService
/// </summary>
public async Task<RevertResult> RevertMergeAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (task, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
if (task.Status != TaskStatus.Done)
return RevertBlocked("task is not Done; only a merged task's revert can be undone");
@@ -353,7 +444,7 @@ public sealed class TaskMergeService
/// </summary>
public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
@@ -388,7 +479,7 @@ public sealed class TaskMergeService
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
@@ -399,7 +490,7 @@ public sealed class TaskMergeService
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return new MergeTargets("", Array.Empty<string>());
@@ -411,7 +502,7 @@ public sealed class TaskMergeService
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (_, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
if (wt is null || wt.State != WorktreeState.Active)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
@@ -438,7 +529,7 @@ public sealed class TaskMergeService
public async Task<MergeResult> ApproveAndMergeAsync(
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (task, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review");
@@ -0,0 +1,75 @@
using System.Diagnostics;
using System.Text;
namespace ClaudeDo.Worker.Lifecycle;
/// <summary>
/// Runs a list's configured post-merge verification command (e.g. a build/test invocation)
/// via cmd.exe, mirroring GitService's ProcessStartInfo discipline (no shell string
/// concatenation beyond the single /c argument cmd.exe itself requires to parse a command line).
/// </summary>
public sealed class VerifyCommandRunner : IVerifyCommandRunner
{
// Safety cap so a runaway/chatty command can't exhaust memory; only the tail matters anyway.
private const int MaxOutputChars = 512_000;
public async Task<VerifyCommandResult> RunAsync(
string workingDir, string command, TimeSpan timeout, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = "cmd.exe",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
psi.ArgumentList.Add("/c");
psi.ArgumentList.Add(command);
using var process = new Process { StartInfo = psi };
var output = new StringBuilder();
var sync = new object();
void Append(string? line)
{
if (line is null) return;
lock (sync)
{
if (output.Length >= MaxOutputChars) return;
output.AppendLine(line);
}
}
process.OutputDataReceived += (_, e) => Append(e.Data);
process.ErrorDataReceived += (_, e) => Append(e.Data);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
var timedOut = false;
// On cancellation (timeout or caller): kill the tree. Killing unblocks WaitForExitAsync
// below and lets the async output readers drain/complete naturally.
await using var ctr = cts.Token.Register(() =>
{
timedOut = !ct.IsCancellationRequested;
try { process.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
await process.WaitForExitAsync(CancellationToken.None);
string finalOutput;
lock (sync) finalOutput = output.ToString();
return new VerifyCommandResult(process.ExitCode, timedOut, finalOutput);
}
}
+1
View File
@@ -83,6 +83,7 @@ builder.Services.AddSingleton<IInteractiveLaunchSpecService, InteractiveLaunchSp
builder.Services.AddSingleton<TaskRunner>();
builder.Services.AddSingleton<WorktreeMaintenanceService>();
builder.Services.AddSingleton<TaskResetService>();
builder.Services.AddSingleton<IVerifyCommandRunner, VerifyCommandRunner>();
builder.Services.AddSingleton<TaskMergeService>();
builder.Services.AddSingleton<PlanningAggregator>();
builder.Services.AddSingleton<PlanningMergeOrchestrator>();