Merge claudedo/d8199f1f3df3447f8de2e7aca9ec5064

This commit is contained in:
mika kuns
2026-08-11 17:48:21 +02:00
5 changed files with 168 additions and 27 deletions
@@ -6,6 +6,7 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Lifecycle;
@@ -68,6 +69,14 @@ public sealed class TaskMergeService
// per-list configurable value on top of what the spec calls for.
private static readonly TimeSpan VerifyTimeout = TimeSpan.FromMinutes(10);
// Mirrors TaskWaitMcpTools.ProgressReportInterval (External/TaskWaitMcpTools.cs): a verify
// run can take up to VerifyTimeout, well past Claude Code's ~300s MCP idle-silence abort, so
// RunReportingProgressAsync below reports on this cadence to keep the calling review_task/
// merge_task/preview_merge* call alive. Not readonly -- tests shrink it to observe a report
// without waiting 30s. A separate field from TaskWaitMcpTools' own (rather than sharing it)
// so shrinking one for a test can't race the other's tests.
internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30);
// 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.
@@ -122,14 +131,16 @@ public sealed class TaskMergeService
/// CLAUDE.md — only the Done transition is withheld).
/// </summary>
private async Task<MergeResult?> RunVerifyGateAsync(
string? verifyCommand, string workingDir, CancellationToken ct)
string? verifyCommand, string workingDir, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
if (string.IsNullOrWhiteSpace(verifyCommand)) return null;
VerifyCommandResult result;
try
{
result = await _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct);
result = await RunReportingProgressAsync(
_verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), progress, "verify gate running");
}
catch (Exception ex)
{
@@ -146,6 +157,26 @@ public sealed class TaskMergeService
return new MergeResult(StatusVerifyFailed, Array.Empty<string>(), $"{reason}\n{TailOutput(result.Output)}");
}
/// <summary>
/// Awaits <paramref name="work"/> while reporting MCP progress every
/// <see cref="ProgressReportInterval"/> so a caller waiting on a long verify run doesn't hit
/// the MCP client's own idle-silence abort. No-op passthrough when <paramref name="progress"/>
/// is null (every non-MCP caller, e.g. the Hub).
/// </summary>
private static async Task<T> RunReportingProgressAsync<T>(
Task<T> work, IProgress<ProgressNotificationValue>? progress, string message)
{
if (progress is null) return await work;
var sw = System.Diagnostics.Stopwatch.StartNew();
while (true)
{
var finished = await Task.WhenAny(work, Task.Delay(ProgressReportInterval));
if (finished == work) return await work;
progress.Report(new ProgressNotificationValue { Progress = 0, Message = $"{message} ({sw.Elapsed:mm\\:ss})" });
}
}
private static string TailOutput(string output, int maxChars = 4000)
{
var trimmed = output.Trim();
@@ -335,7 +366,8 @@ public sealed class TaskMergeService
bool removeWorktree,
string commitMessage,
bool leaveConflictsInTree,
CancellationToken ct)
CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
@@ -425,7 +457,7 @@ public sealed class TaskMergeService
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct);
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct);
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct, progress);
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
@@ -722,7 +754,8 @@ public sealed class TaskMergeService
/// delay, no scratch worktree.
/// </summary>
public async Task<MergePreviewResult> PreviewAsync(
string taskId, string targetBranch, bool runVerify, CancellationToken ct)
string taskId, string targetBranch, bool runVerify, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
var (_, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
@@ -753,7 +786,7 @@ public sealed class TaskMergeService
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
var (exitCode, durationMs, outputTail) = await RunPreviewVerifyAsync(
list.WorkingDir, target, preview.TreeOid, verifyCommand, ct);
list.WorkingDir, target, preview.TreeOid, verifyCommand, ct, progress);
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count, exitCode, durationMs, outputTail);
}
@@ -764,7 +797,8 @@ public sealed class TaskMergeService
/// cancellation.
/// </summary>
private async Task<(int ExitCode, long DurationMs, string OutputTail)> RunPreviewVerifyAsync(
string repoDir, string targetBranch, string treeOid, string verifyCommand, CancellationToken ct)
string repoDir, string targetBranch, string treeOid, string verifyCommand, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var scratchPath = Path.Combine(Path.GetTempPath(), "claudedo-preview-verify", Guid.NewGuid().ToString("N"));
@@ -778,7 +812,8 @@ public sealed class TaskMergeService
VerifyCommandResult result;
try
{
result = await _verify.RunAsync(scratchPath, verifyCommand, VerifyTimeout, ct);
result = await RunReportingProgressAsync(
_verify.RunAsync(scratchPath, verifyCommand, VerifyTimeout, ct), progress, "merge preview verify running");
}
catch (Exception ex)
{
@@ -808,7 +843,8 @@ public sealed class TaskMergeService
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);
public async Task<MergeResult> ApproveAndMergeAsync(
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct)
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
@@ -828,7 +864,7 @@ public sealed class TaskMergeService
await verifyGate.WaitAsync(ct);
try
{
var failed = await RunVerifyGateAsync(verifyCommand, list.WorkingDir!, ct);
var failed = await RunVerifyGateAsync(verifyCommand, list.WorkingDir!, ct, progress);
if (failed is not null) return failed;
}
finally { verifyGate.Release(); }
@@ -850,7 +886,7 @@ public sealed class TaskMergeService
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
// Remove the worktree on approve (matching the unit-merge path) so merged
// worktrees don't pile up; the merge commit on the target branch is the record.
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", leaveConflictsInTree, ct);
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", leaveConflictsInTree, ct, progress);
}
private static MergeResult Blocked(string reason) =>