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:
mika kuns
2026-08-11 19:14:36 +02:00
parent fc9df7f9ac
commit cad0582b37
22 changed files with 405 additions and 31 deletions
+1 -1
View File
@@ -673,7 +673,7 @@
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
+1 -1
View File
@@ -673,7 +673,7 @@
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
@@ -34,6 +34,11 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent;
/// <summary>(taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge
/// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the
/// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s.</summary>
event Action<string, string, int>? MergeProgressEvent;
event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent;
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
+9 -1
View File
@@ -64,6 +64,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
public event Action<string, string, int>? MergeProgressEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
@@ -172,6 +174,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc)));
});
_hub.On<string, string, int>("MergeProgress", (taskId, phase, elapsedSeconds) =>
{
Dispatcher.UIThread.Post(() => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds));
});
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
@@ -694,7 +701,8 @@ public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles,
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
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);
@@ -21,6 +21,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
[ObservableProperty] private string _commitMessage = "";
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
/// What the pending merge is doing right now, fed by the worker's MergeProgress broadcast.
/// A merge whose list has a verify command can occupy this call for minutes — without this the
/// modal only greys the button out and looks dead.
[ObservableProperty] private string? _progressMessage;
[ObservableProperty] private string? _errorMessage;
[ObservableProperty] private string? _warningMessage;
[ObservableProperty] private string? _successMessage;
@@ -58,6 +64,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
ErrorMessage = Loc.T("vm.merge.workerOfflineBranches");
return;
}
// The worker owns the default message — only it knows the task's commit type and the
// list name the scope is slugged from. The locale string stays as the offline fallback.
if (!string.IsNullOrWhiteSpace(targets.DefaultCommitMessage))
CommitMessage = targets.DefaultCommitMessage;
foreach (var b in targets.LocalBranches) Branches.Add(b);
SelectedBranch = Branches.Contains(targets.DefaultBranch)
? targets.DefaultBranch
@@ -81,6 +91,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
ErrorMessage = null;
WarningMessage = null;
SuccessMessage = null;
// Subscribed only for the duration of the call: the worker's broadcast reaches every
// client, and a transient VM left on that event would outlive its window.
ProgressMessage = Loc.T("vm.merge.progressMerging");
_worker.MergeProgressEvent += OnMergeProgress;
try
{
var result = await _worker.MergeTaskAsync(
@@ -133,10 +147,28 @@ public sealed partial class MergeModalViewModel : ViewModelBase
}
finally
{
_worker.MergeProgressEvent -= OnMergeProgress;
ProgressMessage = null;
IsBusy = false;
}
}
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
{
if (taskId != TaskId) return;
ProgressMessage = phase switch
{
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
_ => Loc.T("vm.merge.progressMerging"),
};
}
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
private const string MergePhaseVerifying = "verifying";
private static string FormatElapsed(int seconds) =>
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}
@@ -440,8 +440,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
MergeResultDto result;
try
{
result = await mergeFn(row.TaskId, target!, false,
Loc.T("vm.merge.commitMessage", row.TaskTitle));
// Blank message: the worker builds the conventional default per task.
result = await mergeFn(row.TaskId, target!, false, "");
}
catch
{
@@ -22,6 +22,15 @@
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<!-- A merge into a list with a verify command holds this dialog for minutes; without a
live phase here the disabled button is the only feedback and reads as a dead app. -->
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"
IsVisible="{Binding IsBusy}">
<Ellipse Classes="spinner" VerticalAlignment="Center"/>
<TextBlock Classes="meta" VerticalAlignment="Center"
Text="{Binding ProgressMessage}"
IsVisible="{Binding ProgressMessage, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
<Button Classes="btn" Content="{loc:Tr modals.merge.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{loc:Tr modals.merge.merge}" Classes="primary"
Command="{Binding SubmitCommand}"
+2 -1
View File
@@ -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
View File
@@ -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);
+7 -4
View File
@@ -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();