Merge claudedo/d8199f1f3df3447f8de2e7aca9ec5064
This commit is contained in:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user