fix(merge): conventional merge-commit default and live verify progress
The merge commit message was hand-rolled per caller ("Merge task: <title>",
"Merge <branch>", "Merge subtask") and ignored the task's commit type. Every
caller now passes a blank message and TaskMergeService fills in
CommitMessageBuilder.BuildMerge -> {commitType}(list-slug): merge <title> plus the
ClaudeDo-Task trailer; the merge modal prefills it from GetMergeTargets.
A merge whose list has a verify command holds the MergeTask call for minutes (5m46s
on this repo), during which the modal only disabled its button - no spinner, no
message, so a landed merge looked like a dead app. TaskMergeService now broadcasts
MergeProgress(taskId, phase, elapsedSeconds) for the merging and verifying phases
(re-reported every 30s) plus a WorkerLog line when verify starts; the modal shows a
spinner and the localized phase.
This commit is contained in:
@@ -120,7 +120,7 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
|
||||
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
||||
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
|
||||
- **WorktreeManager** — worktrees on `claudedo/{taskId[:8]}` branches; commits with semantic messages, updates DB with head commit + diff stats
|
||||
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
|
||||
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
|
||||
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
|
||||
- **AgentFileService** — manages `~/.todo-app/agents/*.md`; list/refresh via SignalR
|
||||
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
|
||||
@@ -168,6 +168,7 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
|
||||
- `PrepStarted`
|
||||
- `PrepLine`
|
||||
- `PrepFinished`
|
||||
- `MergeProgress`
|
||||
- `PlanningMergeStarted`
|
||||
- `PlanningSubtaskMerged`
|
||||
- `PlanningMergeConflict`
|
||||
|
||||
+2
-2
@@ -997,9 +997,9 @@ public sealed class ExternalMcpService
|
||||
return new MergeTaskResultDto(false, null, []);
|
||||
}
|
||||
|
||||
var commitMessage = $"Merge task branch for: {task.Title}";
|
||||
// Blank on purpose: TaskMergeService builds the conventional default message.
|
||||
var result = await _merge.MergeAsync(
|
||||
taskId, targetBranch, removeWorktree: false, commitMessage, leaveConflictsInTree, cancellationToken, progress);
|
||||
taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress);
|
||||
|
||||
if (result.Status == TaskMergeService.StatusMerged)
|
||||
{
|
||||
|
||||
@@ -46,6 +46,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
||||
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
||||
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
||||
|
||||
// Phase of an in-flight single-task merge (see TaskMergeService.Phase*), so a client waiting
|
||||
// on the MergeTask call can show what it is waiting for instead of a frozen button.
|
||||
public Task MergeProgress(string taskId, string phase, int elapsedSeconds) =>
|
||||
_hub.Clients.All.SendAsync("MergeProgress", taskId, phase, elapsedSeconds);
|
||||
|
||||
public Task PlanningMergeStarted(string planningTaskId, string targetBranch) =>
|
||||
_hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch);
|
||||
|
||||
|
||||
@@ -96,7 +96,8 @@ public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
|
||||
public record MergePreviewDto(
|
||||
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount,
|
||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null);
|
||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
public record MergeTargetsDto(
|
||||
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage);
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
@@ -588,11 +589,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
// A blank message is handed through deliberately — TaskMergeService builds the
|
||||
// conventional default from the task's commit type and its list's name.
|
||||
var r = await _mergeService.MergeAsync(
|
||||
taskId,
|
||||
targetBranch ?? "",
|
||||
removeWorktree,
|
||||
string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage,
|
||||
commitMessage,
|
||||
CancellationToken.None);
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
});
|
||||
@@ -601,7 +604,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage);
|
||||
});
|
||||
|
||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
||||
@@ -615,7 +618,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var r = await _mergeService.MergeAsync(
|
||||
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
|
||||
taskId, targetBranch ?? "", removeWorktree: false, commitMessage: "",
|
||||
leaveConflictsInTree: true, CancellationToken.None);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "merge blocked");
|
||||
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol;
|
||||
@@ -18,7 +19,8 @@ public sealed record MergeResult(
|
||||
|
||||
public sealed record MergeTargets(
|
||||
string DefaultBranch,
|
||||
IReadOnlyList<string> LocalBranches);
|
||||
IReadOnlyList<string> LocalBranches,
|
||||
string DefaultCommitMessage);
|
||||
|
||||
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
|
||||
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
|
||||
@@ -59,6 +61,10 @@ public sealed class TaskMergeService
|
||||
public const string StatusReverted = "reverted";
|
||||
public const string StatusConflictAborted = "conflict_aborted";
|
||||
|
||||
// Phase tokens for the MergeProgress broadcast — stable identifiers, localized by the UI.
|
||||
public const string PhaseMerging = "merging";
|
||||
public const string PhaseVerifying = "verifying";
|
||||
|
||||
public const string PreviewClean = "clean";
|
||||
public const string PreviewConflict = "conflict";
|
||||
public const string PreviewUnavailable = "unavailable";
|
||||
@@ -132,7 +138,8 @@ public sealed class TaskMergeService
|
||||
/// </summary>
|
||||
private async Task<MergeResult?> RunVerifyGateAsync(
|
||||
string? verifyCommand, string workingDir, CancellationToken ct,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
IProgress<ProgressNotificationValue>? progress = null,
|
||||
Action<TimeSpan>? onTick = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(verifyCommand)) return null;
|
||||
|
||||
@@ -140,7 +147,7 @@ public sealed class TaskMergeService
|
||||
try
|
||||
{
|
||||
result = await RunReportingProgressAsync(
|
||||
_verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), progress, "verify gate running");
|
||||
_verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), progress, "verify gate running", onTick);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -160,20 +167,23 @@ public sealed class TaskMergeService
|
||||
/// <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).
|
||||
/// the MCP client's own idle-silence abort. <paramref name="onTick"/> rides the same cadence
|
||||
/// for non-MCP callers (the Hub, which turns it into a MergeProgress broadcast). No-op
|
||||
/// passthrough when both are null.
|
||||
/// </summary>
|
||||
private static async Task<T> RunReportingProgressAsync<T>(
|
||||
Task<T> work, IProgress<ProgressNotificationValue>? progress, string message)
|
||||
Task<T> work, IProgress<ProgressNotificationValue>? progress, string message,
|
||||
Action<TimeSpan>? onTick = null)
|
||||
{
|
||||
if (progress is null) return await work;
|
||||
if (progress is null && onTick 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})" });
|
||||
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = $"{message} ({sw.Elapsed:mm\\:ss})" });
|
||||
onTick?.Invoke(sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +390,10 @@ public sealed class TaskMergeService
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||
return Blocked("list has no working directory");
|
||||
|
||||
// Announced before the gate wait: another merge holding the repo is itself a reason the
|
||||
// caller sees nothing happen, and a UI waiting on this call needs a phase to show at once.
|
||||
await _broadcaster.MergeProgress(taskId, PhaseMerging, 0);
|
||||
|
||||
var gate = GetMergeGate(list.WorkingDir);
|
||||
await gate.WaitAsync(ct);
|
||||
try
|
||||
@@ -402,7 +416,10 @@ public sealed class TaskMergeService
|
||||
if (collision is not null) return collision;
|
||||
|
||||
var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct);
|
||||
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
|
||||
var message = string.IsNullOrWhiteSpace(commitMessage)
|
||||
? DefaultMergeMessage(task, list)
|
||||
: commitMessage;
|
||||
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
List<string> files;
|
||||
@@ -457,7 +474,20 @@ 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, progress);
|
||||
// The merge itself is instant; the verify gate is what makes this call take minutes.
|
||||
// Tell every client (the waiting modal and the footer log strip) that it started —
|
||||
// silence here is what makes a working merge look like a dead button.
|
||||
if (!string.IsNullOrWhiteSpace(verifyCommand))
|
||||
{
|
||||
await _broadcaster.MergeProgress(taskId, PhaseVerifying, 0);
|
||||
await _broadcaster.WorkerLog(
|
||||
$"Verify command running after merging #{task.Number} \"{task.Title}\" into {targetBranch}",
|
||||
WorkerLogLevel.Info, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
var verifyFailure = await RunVerifyGateAsync(
|
||||
verifyCommand, list.WorkingDir, ct, progress,
|
||||
elapsed => _ = _broadcaster.MergeProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds));
|
||||
if (verifyFailure is not null)
|
||||
{
|
||||
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
|
||||
@@ -732,16 +762,22 @@ public sealed class TaskMergeService
|
||||
|
||||
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
|
||||
var (task, list, _, _) = await LoadMergeContextAsync(taskId, ct);
|
||||
var defaultMessage = DefaultMergeMessage(task, list);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||
return new MergeTargets("", Array.Empty<string>());
|
||||
return new MergeTargets("", Array.Empty<string>(), defaultMessage);
|
||||
|
||||
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
||||
return new MergeTargets(current, branches);
|
||||
return new MergeTargets(current, branches, defaultMessage);
|
||||
}
|
||||
|
||||
/// The commit message a merge uses when the caller passes none. Built here rather than in the
|
||||
/// UI/MCP callers because only this layer knows the task's commit type and its list's name.
|
||||
private static string DefaultMergeMessage(TaskEntity task, ListEntity list) =>
|
||||
CommitMessageBuilder.BuildMerge(task.CommitType, list.Name, task.Title, task.Id);
|
||||
|
||||
public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
=> PreviewAsync(taskId, targetBranch, runVerify: false, ct);
|
||||
|
||||
@@ -861,10 +897,14 @@ public sealed class TaskMergeService
|
||||
if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||
{
|
||||
var verifyGate = GetMergeGate(list.WorkingDir!);
|
||||
await _broadcaster.MergeProgress(taskId, PhaseVerifying, 0);
|
||||
await verifyGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var failed = await RunVerifyGateAsync(verifyCommand, list.WorkingDir!, ct, progress);
|
||||
// Same reason as the post-merge gate: this holds the approve call for minutes.
|
||||
var failed = await RunVerifyGateAsync(
|
||||
verifyCommand, list.WorkingDir!, ct, progress,
|
||||
elapsed => _ = _broadcaster.MergeProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds));
|
||||
if (failed is not null) return failed;
|
||||
}
|
||||
finally { verifyGate.Release(); }
|
||||
@@ -886,7 +926,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, progress);
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress);
|
||||
}
|
||||
|
||||
private static MergeResult Blocked(string reason) =>
|
||||
|
||||
@@ -227,7 +227,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
||||
subtaskId,
|
||||
state.TargetBranch,
|
||||
removeWorktree: true,
|
||||
commitMessage: "Merge subtask",
|
||||
commitMessage: "", // blank -> TaskMergeService builds the conventional default
|
||||
leaveConflictsInTree: true,
|
||||
ct);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
@@ -28,6 +29,23 @@ public static class CommitMessageBuilder
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message for the merge commit that lands a task branch. Same Conventional-Commits header
|
||||
/// shape as the task's own commits (<see cref="Build"/>) with an explicit `merge ` verb, so
|
||||
/// merge commits stay parseable *and* greppable, plus the task-id trailer. A blank commit
|
||||
/// type falls back to <see cref="CommitTypeRegistry.DefaultType"/>; a list name that slugs
|
||||
/// to nothing drops the scope rather than emitting an empty `()`.
|
||||
/// </summary>
|
||||
public static string BuildMerge(string commitType, string listName, string taskTitle, string taskId)
|
||||
{
|
||||
var type = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType.Trim();
|
||||
var slug = ToSlug(listName);
|
||||
var scope = slug.Length == 0 ? "" : $"({slug})";
|
||||
var title = Truncate(taskTitle.Trim(), 60);
|
||||
|
||||
return $"{type}{scope}: merge {title}\n\nClaudeDo-Task: {taskId}";
|
||||
}
|
||||
|
||||
public static string ToSlug(string name)
|
||||
{
|
||||
var lower = name.ToLowerInvariant();
|
||||
|
||||
Reference in New Issue
Block a user