chore(claude-do): merge fix(worker): Unit-Merge-Fehler propagieren statt als Erfolg
ClaudeDo-Task: 3dc0f26a-53cb-40bb-9f22-5df2e6d61c84
This commit is contained in:
@@ -76,6 +76,23 @@ DB write. Cycle note: `TaskStateService` can't take a direct constructor depende
|
|||||||
handed to `PlanningChainCoordinator`. `IActiveMergeState` (`Planning/Interfaces/`) is `PlanningMergeOrchestrator`'s
|
handed to `PlanningChainCoordinator`. `IActiveMergeState` (`Planning/Interfaces/`) is `PlanningMergeOrchestrator`'s
|
||||||
only public surface `TaskStateService` needs.
|
only public surface `TaskStateService` needs.
|
||||||
|
|
||||||
|
`HasActiveMerge` also stays true through the window between the last child merging and
|
||||||
|
`FinalizeParentDoneAsync` returning (a separate `State.IsFinalizing` flag — `CurrentSubtaskId` is
|
||||||
|
already null there since no subtask is left to merge). Without it, a `Cancel` racing the finalize
|
||||||
|
call's own `ApproveReviewAsync` could slip past the guard for that whole call. `StartAsync` also now
|
||||||
|
requires the parent to already be `WaitingForReview` — for planning **and** improvement parents —
|
||||||
|
before touching anything; previously only planning parents got an (indirect, children-only) check,
|
||||||
|
so a stale UI click or a second caller on an improvement parent that had already left
|
||||||
|
`WaitingForReview` could still drive a partial child merge before the final approve refused.
|
||||||
|
|
||||||
|
Unit-merge failures used to vanish silently: a child merge that came back
|
||||||
|
`blocked`/`verify_failed`/`untracked_collision` (not `conflict`) was only logged server-side, and
|
||||||
|
`ApproveReview`/`review_task` approve always reported success (`StatusMerged`) regardless.
|
||||||
|
`StartAsync`/`ContinueAsync`/the private `DrainAsync` now return a `PlanningMergeResult(Status,
|
||||||
|
Reason)` that both hub paths propagate as the real status, and `PlanningMergeAborted` carries that
|
||||||
|
reason so the UI's `IslandsShellViewModel.OnPlanningMergeAborted` can `FlashFooterError` it instead
|
||||||
|
of only clearing the banner.
|
||||||
|
|
||||||
The UI mirrors this as polish: `DetailsIslandViewModel.IsMergeDraining` (set on
|
The UI mirrors this as polish: `DetailsIslandViewModel.IsMergeDraining` (set on
|
||||||
`PlanningMergeStartedEvent`, cleared on `PlanningMergeAborted`/`PlanningCompleted` for the bound
|
`PlanningMergeStartedEvent`, cleared on `PlanningMergeAborted`/`PlanningCompleted` for the bound
|
||||||
task) gates `CancelReviewCommand`'s `CanExecute` — same shape as `WorktreesOverviewModalViewModel.IsMerging`
|
task) gates `CancelReviewCommand`'s `CanExecute` — same shape as `WorktreesOverviewModalViewModel.IsMerging`
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// is true when an MCP session (not the UI) started the unit merge — the resolver must not
|
/// is true when an MCP session (not the UI) started the unit merge — the resolver must not
|
||||||
/// auto-open in that case.</summary>
|
/// auto-open in that case.</summary>
|
||||||
event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||||
event Action<string>? PlanningMergeAbortedEvent;
|
/// <summary>(planningTaskId, reason). reason is set when the merge stopped on a real failure
|
||||||
|
/// (blocked/verify_failed/untracked_collision) rather than a deliberate abort.</summary>
|
||||||
|
event Action<string, string?>? PlanningMergeAbortedEvent;
|
||||||
event Action<string>? PlanningCompletedEvent;
|
event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
event Action<PrimeFiredEvent>? PrimeFired;
|
event Action<PrimeFiredEvent>? PrimeFired;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||||
public event Action<string>? PlanningMergeAbortedEvent;
|
public event Action<string, string?>? PlanningMergeAbortedEvent;
|
||||||
public event Action<string>? PlanningCompletedEvent;
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||||
@@ -199,9 +199,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles, externallyDriven));
|
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles, externallyDriven));
|
||||||
});
|
});
|
||||||
|
|
||||||
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
|
_hub.On<string, string?>("PlanningMergeAborted", (planningTaskId, reason) =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => PlanningMergeAbortedEvent?.Invoke(planningTaskId));
|
Dispatcher.UIThread.Post(() => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason));
|
||||||
});
|
});
|
||||||
|
|
||||||
_hub.On<string>("PlanningCompleted", planningTaskId =>
|
_hub.On<string>("PlanningCompleted", planningTaskId =>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private readonly Action<string> _workerWorktreeUpdatedHandler;
|
private readonly Action<string> _workerWorktreeUpdatedHandler;
|
||||||
private readonly Action<string> _workerTaskUpdatedHandler;
|
private readonly Action<string> _workerTaskUpdatedHandler;
|
||||||
private readonly Action<string, string> _workerPlanningMergeStartedHandler;
|
private readonly Action<string, string> _workerPlanningMergeStartedHandler;
|
||||||
private readonly Action<string> _workerPlanningMergeAbortedHandler;
|
private readonly Action<string, string?> _workerPlanningMergeAbortedHandler;
|
||||||
private readonly Action<string> _workerPlanningCompletedHandler;
|
private readonly Action<string> _workerPlanningCompletedHandler;
|
||||||
|
|
||||||
[ObservableProperty] private bool _isNotesMode;
|
[ObservableProperty] private bool _isNotesMode;
|
||||||
@@ -432,7 +432,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
};
|
};
|
||||||
_worker.PlanningMergeStartedEvent += _workerPlanningMergeStartedHandler;
|
_worker.PlanningMergeStartedEvent += _workerPlanningMergeStartedHandler;
|
||||||
|
|
||||||
_workerPlanningMergeAbortedHandler = planningTaskId =>
|
_workerPlanningMergeAbortedHandler = (planningTaskId, _) =>
|
||||||
{
|
{
|
||||||
if (Task?.Id == planningTaskId) IsMergeDraining = false;
|
if (Task?.Id == planningTaskId) IsMergeDraining = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -218,7 +218,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnPlanningMergeAborted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
|
public void OnPlanningMergeAborted(string planningTaskId, string? reason = null)
|
||||||
|
{
|
||||||
|
ClearExternalMergeConflict(planningTaskId);
|
||||||
|
// A deliberate abort/conflict pause and a real merge failure both flow through this
|
||||||
|
// event; only the latter carries a reason worth surfacing in the footer strip.
|
||||||
|
if (!string.IsNullOrWhiteSpace(reason))
|
||||||
|
FlashFooterError(reason);
|
||||||
|
}
|
||||||
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
|
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
|
||||||
|
|
||||||
private void ClearExternalMergeConflict(string planningTaskId)
|
private void ClearExternalMergeConflict(string planningTaskId)
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
|
|||||||
- `PlanningMergeStarted`
|
- `PlanningMergeStarted`
|
||||||
- `PlanningSubtaskMerged`
|
- `PlanningSubtaskMerged`
|
||||||
- `PlanningMergeConflict`
|
- `PlanningMergeConflict`
|
||||||
- `PlanningMergeAborted`
|
- `PlanningMergeAborted` (`planningTaskId, reason` — every call site passes a human-readable
|
||||||
|
reason now, from a real merge failure's `ErrorMessage` to a plain "Merge aborted."; the UI
|
||||||
|
flashes it via `FlashFooterError`)
|
||||||
- `PlanningCompleted`
|
- `PlanningCompleted`
|
||||||
- `RefineStarted`
|
- `RefineStarted`
|
||||||
- `RefineFinished`
|
- `RefineFinished`
|
||||||
|
|||||||
+9
-4
@@ -703,16 +703,21 @@ public sealed class ExternalMcpService
|
|||||||
// externallyDriven: true — this call came from an MCP session, not the UI's
|
// externallyDriven: true — this call came from an MCP session, not the UI's
|
||||||
// Approve button. A unit-merge conflict must not auto-open the in-app resolver;
|
// Approve button. A unit-merge conflict must not auto-open the in-app resolver;
|
||||||
// the driving session resolves it via continue_merge/abort_merge instead.
|
// the driving session resolves it via continue_merge/abort_merge instead.
|
||||||
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress);
|
var startResult = await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress);
|
||||||
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
|
if (startResult.Status == TaskMergeService.StatusBlocked)
|
||||||
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
|
throw new InvalidOperationException(startResult.Reason ?? "approve failed");
|
||||||
if (!parentDone)
|
mergeStatus = startResult.Status;
|
||||||
|
if (startResult.Status == TaskMergeService.StatusConflict)
|
||||||
{
|
{
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||||
repoPath = list?.WorkingDir;
|
repoPath = list?.WorkingDir;
|
||||||
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
|
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
|
||||||
"resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
|
"resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
|
||||||
}
|
}
|
||||||
|
else if (startResult.Status != TaskMergeService.StatusMerged)
|
||||||
|
{
|
||||||
|
mergeMessage = startResult.Reason;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
|||||||
string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven) =>
|
string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven) =>
|
||||||
_hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files, externallyDriven);
|
_hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files, externallyDriven);
|
||||||
|
|
||||||
public Task PlanningMergeAborted(string planningTaskId) =>
|
public Task PlanningMergeAborted(string planningTaskId, string? reason) =>
|
||||||
_hub.Clients.All.SendAsync("PlanningMergeAborted", planningTaskId);
|
_hub.Clients.All.SendAsync("PlanningMergeAborted", planningTaskId, reason);
|
||||||
|
|
||||||
public Task PlanningCompleted(string planningTaskId) =>
|
public Task PlanningCompleted(string planningTaskId) =>
|
||||||
_hub.Clients.All.SendAsync("PlanningCompleted", planningTaskId);
|
_hub.Clients.All.SendAsync("PlanningCompleted", planningTaskId);
|
||||||
|
|||||||
@@ -744,8 +744,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
|
|
||||||
if (hasChildren)
|
if (hasChildren)
|
||||||
{
|
{
|
||||||
await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
var unitResult = await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||||
return new MergeResultDto(TaskMergeService.StatusMerged, Array.Empty<string>(), null);
|
if (unitResult.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(unitResult.Reason ?? "approve failed");
|
||||||
|
return new MergeResultDto(unitResult.Status, Array.Empty<string>(), unitResult.Reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ namespace ClaudeDo.Worker.Planning;
|
|||||||
/// <summary>A unit-merge conflict currently paused, driven by an MCP session rather than the UI.</summary>
|
/// <summary>A unit-merge conflict currently paused, driven by an MCP session rather than the UI.</summary>
|
||||||
public sealed record ExternalPlanningMergeConflict(string PlanningTaskId, string SubtaskId);
|
public sealed record ExternalPlanningMergeConflict(string PlanningTaskId, string SubtaskId);
|
||||||
|
|
||||||
|
/// <summary>Outcome of a unit-merge drive (StartAsync/ContinueAsync). Status mirrors
|
||||||
|
/// <see cref="TaskMergeService"/>'s status strings — StatusMerged/StatusConflict on the two
|
||||||
|
/// "everything worked (so far)" paths, or the real blocked/verify_failed/untracked_collision
|
||||||
|
/// status with its Reason when a child merge (or the final approve) failed outright.</summary>
|
||||||
|
public sealed record PlanningMergeResult(string Status, string? Reason)
|
||||||
|
{
|
||||||
|
public static readonly PlanningMergeResult Merged = new(TaskMergeService.StatusMerged, null);
|
||||||
|
public static readonly PlanningMergeResult Conflict = new(TaskMergeService.StatusConflict, null);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
@@ -35,6 +45,12 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
/// merge must not auto-pop the in-app resolver — the driving session owns resolution.</summary>
|
/// merge must not auto-pop the in-app resolver — the driving session owns resolution.</summary>
|
||||||
public required bool ExternallyDriven { get; init; }
|
public required bool ExternallyDriven { get; init; }
|
||||||
public string? CurrentSubtaskId { get; set; }
|
public string? CurrentSubtaskId { get; set; }
|
||||||
|
/// <summary>True from the moment the last child has merged until FinalizeParentDoneAsync
|
||||||
|
/// returns. CurrentSubtaskId is already null in this window (no subtask left to merge),
|
||||||
|
/// so HasActiveMerge needs this separate flag — otherwise a Cancel racing the finalize call
|
||||||
|
/// would slip past TaskStateService.CancelAsync's guard while the parent is still being
|
||||||
|
/// flipped to Done.</summary>
|
||||||
|
public bool IsFinalizing { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, State> _states = new();
|
private readonly ConcurrentDictionary<string, State> _states = new();
|
||||||
@@ -57,7 +73,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartAsync(
|
public async Task<PlanningMergeResult> StartAsync(
|
||||||
string parentTaskId, string targetBranch, CancellationToken ct, bool externallyDriven = false,
|
string parentTaskId, string targetBranch, CancellationToken ct, bool externallyDriven = false,
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
{
|
{
|
||||||
@@ -65,6 +81,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
List<TaskEntity> children;
|
List<TaskEntity> children;
|
||||||
bool isPlanning;
|
bool isPlanning;
|
||||||
bool parentHasWorktree;
|
bool parentHasWorktree;
|
||||||
|
TaskStatus parentStatus;
|
||||||
|
|
||||||
using (var ctx = _dbFactory.CreateDbContext())
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
{
|
{
|
||||||
@@ -79,6 +96,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
children = parent.Children.OrderBy(c => c.SortOrder).ToList();
|
children = parent.Children.OrderBy(c => c.SortOrder).ToList();
|
||||||
isPlanning = parent.PlanningPhase != PlanningPhase.None;
|
isPlanning = parent.PlanningPhase != PlanningPhase.None;
|
||||||
parentHasWorktree = parent.Worktree is { State: WorktreeState.Active };
|
parentHasWorktree = parent.Worktree is { State: WorktreeState.Active };
|
||||||
|
parentStatus = parent.Status;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlanning)
|
if (isPlanning)
|
||||||
@@ -95,6 +113,13 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Applies to planning AND improvement parents alike -- a stale UI click or a second
|
||||||
|
// caller after the parent already left WaitingForReview (e.g. cancelled, or a previous
|
||||||
|
// Approve already drove it to Done) must not kick off a partial re-merge of its children.
|
||||||
|
if (parentStatus != TaskStatus.WaitingForReview)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"planning task '{parentTaskId}' is not WaitingForReview (status: {parentStatus})");
|
||||||
|
|
||||||
if (await _git.IsMidMergeAsync(workingDir, ct))
|
if (await _git.IsMidMergeAsync(workingDir, ct))
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"repo is mid-merge; use AbortPlanningMerge to reset the repository, then Approve again");
|
"repo is mid-merge; use AbortPlanningMerge to reset the repository, then Approve again");
|
||||||
@@ -123,12 +148,13 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
|
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
|
||||||
|
|
||||||
await _broadcaster.PlanningMergeStarted(parentTaskId, targetBranch);
|
await _broadcaster.PlanningMergeStarted(parentTaskId, targetBranch);
|
||||||
await DrainAsync(parentTaskId, ct, progress);
|
return await DrainAsync(parentTaskId, ct, progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True when a unit merge for this parent is paused on a conflict (in-memory state).</summary>
|
/// <summary>True when a unit merge for this parent is paused on a conflict, or is in the
|
||||||
|
/// window between the last child merging and FinalizeParentDoneAsync completing.</summary>
|
||||||
public bool HasActiveMerge(string parentTaskId) =>
|
public bool HasActiveMerge(string parentTaskId) =>
|
||||||
_states.TryGetValue(parentTaskId, out var s) && s.CurrentSubtaskId is not null;
|
_states.TryGetValue(parentTaskId, out var s) && (s.CurrentSubtaskId is not null || s.IsFinalizing);
|
||||||
|
|
||||||
/// <summary>Externally-driven unit merges currently paused on a conflict, so the UI can
|
/// <summary>Externally-driven unit merges currently paused on a conflict, so the UI can
|
||||||
/// recover this on reconnect instead of relying solely on the one-shot broadcast. Checked
|
/// recover this on reconnect instead of relying solely on the one-shot broadcast. Checked
|
||||||
@@ -147,7 +173,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ContinueAsync(
|
public async Task<PlanningMergeResult> ContinueAsync(
|
||||||
string planningTaskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null)
|
string planningTaskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null)
|
||||||
{
|
{
|
||||||
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
||||||
@@ -160,7 +186,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
if (result.Status == TaskMergeService.StatusConflict)
|
if (result.Status == TaskMergeService.StatusConflict)
|
||||||
{
|
{
|
||||||
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles, state.ExternallyDriven);
|
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles, state.ExternallyDriven);
|
||||||
return;
|
return PlanningMergeResult.Conflict;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.Status != TaskMergeService.StatusMerged)
|
if (result.Status != TaskMergeService.StatusMerged)
|
||||||
@@ -169,14 +195,14 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
"Planning continue blocked on subtask {Subtask}: {Msg}",
|
"Planning continue blocked on subtask {Subtask}: {Msg}",
|
||||||
current, result.ErrorMessage);
|
current, result.ErrorMessage);
|
||||||
_states.TryRemove(planningTaskId, out _);
|
_states.TryRemove(planningTaskId, out _);
|
||||||
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
await _broadcaster.PlanningMergeAborted(planningTaskId, result.ErrorMessage);
|
||||||
return;
|
return new PlanningMergeResult(result.Status, result.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _broadcaster.PlanningSubtaskMerged(planningTaskId, current);
|
await _broadcaster.PlanningSubtaskMerged(planningTaskId, current);
|
||||||
|
|
||||||
state.CurrentSubtaskId = null;
|
state.CurrentSubtaskId = null;
|
||||||
await DrainAsync(planningTaskId, ct, progress);
|
return await DrainAsync(planningTaskId, ct, progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
|
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
|
||||||
@@ -191,7 +217,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
|
|
||||||
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
|
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
|
||||||
_states.TryRemove(planningTaskId, out _);
|
_states.TryRemove(planningTaskId, out _);
|
||||||
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
await _broadcaster.PlanningMergeAborted(planningTaskId, "Merge aborted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AbortStatelessAsync(string planningTaskId, CancellationToken ct)
|
private async Task AbortStatelessAsync(string planningTaskId, CancellationToken ct)
|
||||||
@@ -212,14 +238,16 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Stateless abort of mid-merge for planning task {ParentId} (post-restart recovery)",
|
"Stateless abort of mid-merge for planning task {ParentId} (post-restart recovery)",
|
||||||
planningTaskId);
|
planningTaskId);
|
||||||
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
await _broadcaster.PlanningMergeAborted(
|
||||||
|
planningTaskId, "Merge aborted after a worker restart. Approve again to restart the merge.");
|
||||||
// Parent remains WaitingForReview — Approve will restart the unit merge from scratch.
|
// Parent remains WaitingForReview — Approve will restart the unit merge from scratch.
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DrainAsync(
|
private async Task<PlanningMergeResult> DrainAsync(
|
||||||
string planningTaskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null)
|
string planningTaskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null)
|
||||||
{
|
{
|
||||||
if (!_states.TryGetValue(planningTaskId, out var state)) return;
|
if (!_states.TryGetValue(planningTaskId, out var state))
|
||||||
|
return new PlanningMergeResult(TaskMergeService.StatusBlocked, "no merge state found for this planning task");
|
||||||
|
|
||||||
var keepState = false;
|
var keepState = false;
|
||||||
try
|
try
|
||||||
@@ -241,7 +269,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
await _broadcaster.PlanningMergeConflict(
|
await _broadcaster.PlanningMergeConflict(
|
||||||
planningTaskId, subtaskId, result.ConflictFiles, state.ExternallyDriven);
|
planningTaskId, subtaskId, result.ConflictFiles, state.ExternallyDriven);
|
||||||
keepState = true;
|
keepState = true;
|
||||||
return;
|
return PlanningMergeResult.Conflict;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.Status != TaskMergeService.StatusMerged)
|
if (result.Status != TaskMergeService.StatusMerged)
|
||||||
@@ -249,17 +277,25 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Planning merge blocked on subtask {Subtask}: {Msg}",
|
"Planning merge blocked on subtask {Subtask}: {Msg}",
|
||||||
subtaskId, result.ErrorMessage);
|
subtaskId, result.ErrorMessage);
|
||||||
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
await _broadcaster.PlanningMergeAborted(planningTaskId, result.ErrorMessage);
|
||||||
return; // keepState stays false → finally removes the state entry
|
return new PlanningMergeResult(result.Status, result.ErrorMessage); // keepState stays false → finally removes the state entry
|
||||||
}
|
}
|
||||||
|
|
||||||
await _broadcaster.PlanningSubtaskMerged(planningTaskId, subtaskId);
|
await _broadcaster.PlanningSubtaskMerged(planningTaskId, subtaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No subtask left to merge, but the parent isn't Done yet -- HasActiveMerge must keep
|
||||||
|
// reporting true through this window (CurrentSubtaskId is already null) so a Cancel
|
||||||
|
// racing FinalizeParentDoneAsync's ApproveReviewAsync call is still refused.
|
||||||
state.CurrentSubtaskId = null;
|
state.CurrentSubtaskId = null;
|
||||||
var finalized = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
|
state.IsFinalizing = true;
|
||||||
|
var (finalized, reason) = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
|
||||||
if (finalized)
|
if (finalized)
|
||||||
|
{
|
||||||
await _broadcaster.PlanningCompleted(planningTaskId);
|
await _broadcaster.PlanningCompleted(planningTaskId);
|
||||||
|
return PlanningMergeResult.Merged;
|
||||||
|
}
|
||||||
|
return new PlanningMergeResult(TaskMergeService.StatusBlocked, reason);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -267,7 +303,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
|
private async Task<(bool Ok, string? Reason)> FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await _state.ApproveReviewAsync(parentTaskId, ct);
|
var result = await _state.ApproveReviewAsync(parentTaskId, ct);
|
||||||
if (!result.Ok)
|
if (!result.Ok)
|
||||||
@@ -288,7 +324,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Unit-merge drain completed but parent {ParentTaskId} could not be finalized (status: {Status}): {Reason}",
|
"Unit-merge drain completed but parent {ParentTaskId} could not be finalized (status: {Status}): {Reason}",
|
||||||
parentTaskId, current, result.Reason);
|
parentTaskId, current, result.Reason);
|
||||||
return false;
|
return (false, result.Reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +335,6 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
|
|||||||
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
|
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return (true, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -43,6 +44,31 @@ public class IslandsShellViewModelExternalMergeTests
|
|||||||
Assert.False(vm.IsExternalMergeBannerVisible);
|
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Covers the unit-merge-error-propagation fix: a real merge failure (blocked/verify_failed/
|
||||||
|
// untracked_collision) reaches the UI as a reason on PlanningMergeAborted, and must surface
|
||||||
|
// in the footer strip rather than silently vanishing once the banner is cleared.
|
||||||
|
[Fact]
|
||||||
|
public void Aborted_WithReason_FlashesFooterError()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
|
||||||
|
vm.OnPlanningMergeAborted("plan1", "child merge blocked: unrelated histories");
|
||||||
|
|
||||||
|
Assert.Equal(WorkerLogLevel.Error, vm.WorkerLogLevel);
|
||||||
|
Assert.Contains("child merge blocked: unrelated histories", vm.WorkerLogText);
|
||||||
|
Assert.True(vm.IsWorkerLogVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Aborted_WithoutReason_DoesNotFlashFooterError()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
|
||||||
|
vm.OnPlanningMergeAborted("plan1", null);
|
||||||
|
|
||||||
|
Assert.False(vm.IsWorkerLogVisible);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Completed_ClearsBanner()
|
public void Completed_ClearsBanner()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||||
public event Action<string>? PlanningMergeAbortedEvent;
|
public event Action<string, string?>? PlanningMergeAbortedEvent;
|
||||||
public event Action<string>? PlanningCompletedEvent;
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||||
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||||
@@ -60,7 +60,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
||||||
public void RaiseMergeProgress(string taskId, string phase, int elapsedSeconds) => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds);
|
public void RaiseMergeProgress(string taskId, string phase, int elapsedSeconds) => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds);
|
||||||
public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
|
public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
|
||||||
public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId);
|
public void RaisePlanningMergeAborted(string planningTaskId, string? reason = null) => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason);
|
||||||
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
||||||
|
|
||||||
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
|
|||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.Lifecycle;
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
using ClaudeDo.Worker.Planning;
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.State;
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -71,7 +72,9 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
|||||||
|
|
||||||
var (orch, calls) = BuildOrchestrator(db);
|
var (orch, calls) = BuildOrchestrator(db);
|
||||||
|
|
||||||
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
var result = await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.Null(result.Reason);
|
||||||
|
|
||||||
using var ctx = db.CreateContext();
|
using var ctx = db.CreateContext();
|
||||||
var planning = ctx.Tasks.Single(t => t.Id == parentId);
|
var planning = ctx.Tasks.Single(t => t.Id == parentId);
|
||||||
@@ -139,14 +142,16 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
|||||||
var (parentId, subA, subB, subC) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
var (parentId, subA, subB, subC) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
||||||
|
|
||||||
var (orch, spy) = BuildOrchestrator(db);
|
var (orch, spy) = BuildOrchestrator(db);
|
||||||
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
var startResult = await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, startResult.Status);
|
||||||
|
|
||||||
Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subA);
|
Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subA);
|
||||||
Assert.Contains(spy, c => c.Method == "PlanningMergeConflict" && (string)c.Args[1]! == subB);
|
Assert.Contains(spy, c => c.Method == "PlanningMergeConflict" && (string)c.Args[1]! == subB);
|
||||||
|
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "resolved\n");
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "resolved\n");
|
||||||
|
|
||||||
await orch.ContinueAsync(parentId, CancellationToken.None);
|
var continueResult = await orch.ContinueAsync(parentId, CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, continueResult.Status);
|
||||||
|
|
||||||
using var ctx = db.CreateContext();
|
using var ctx = db.CreateContext();
|
||||||
Assert.Equal(TaskStatus.Done, ctx.Tasks.Single(t => t.Id == parentId).Status);
|
Assert.Equal(TaskStatus.Done, ctx.Tasks.Single(t => t.Id == parentId).Status);
|
||||||
@@ -608,31 +613,35 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parent is Cancelled before the orchestrator finalizes (simulates a race where the user
|
/// Guard (a): StartAsync now requires the parent to be WaitingForReview up front, for
|
||||||
/// cancels the parent while the merge drain is in progress). After the drain completes,
|
/// improvement parents as much as planning ones. Before this guard existed, a parent that had
|
||||||
/// ApproveReviewAsync sees Status != WaitingForReview and refuses — parent must stay
|
/// already left WaitingForReview (e.g. cancelled by a race, or a stale second Approve click)
|
||||||
/// Cancelled and PlanningCompleted must not be broadcast.
|
/// still had its children merged during the drain, only to have the final ApproveReviewAsync
|
||||||
|
/// refuse at the very end — by then the child worktrees were already unrecoverably merged.
|
||||||
|
/// The fixed behaviour rejects up front: nothing gets touched.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_ParentCancelledBeforeFinalize_StatusRemainsAndNoPlanningCompleted()
|
public async Task StartAsync_ParentNotWaitingForReview_ThrowsWithoutMergingChildren()
|
||||||
{
|
{
|
||||||
var db = NewDb();
|
var db = NewDb();
|
||||||
var repo = NewRepo();
|
var repo = NewRepo();
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
// Improvement parent (PlanningPhase.None) seeded as Cancelled — simulates the race
|
// Improvement parent (PlanningPhase.None) seeded as Cancelled.
|
||||||
// where a user or another thread cancelled the parent during the merge drain.
|
|
||||||
var (parentId, subA, subB) = await SeedCancelledParentWithDoneChildrenAsync(db, repo);
|
var (parentId, subA, subB) = await SeedCancelledParentWithDoneChildrenAsync(db, repo);
|
||||||
|
|
||||||
var (orch, calls) = BuildOrchestrator(db);
|
var (orch, calls) = BuildOrchestrator(db);
|
||||||
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => orch.StartAsync(parentId, "main", CancellationToken.None));
|
||||||
|
Assert.Contains("not WaitingForReview", ex.Message);
|
||||||
|
|
||||||
using var ctx = db.CreateContext();
|
using var ctx = db.CreateContext();
|
||||||
Assert.Equal(TaskStatus.Cancelled, ctx.Tasks.Single(t => t.Id == parentId).Status);
|
Assert.Equal(TaskStatus.Cancelled, ctx.Tasks.Single(t => t.Id == parentId).Status);
|
||||||
Assert.DoesNotContain(calls, c => c.Method == "PlanningCompleted");
|
Assert.Empty(calls);
|
||||||
// Child worktrees were still merged during the drain
|
// Children must stay untouched — the guard rejects before any merge is attempted.
|
||||||
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State);
|
Assert.Equal(WorktreeState.Active, ctx.Worktrees.Single(w => w.TaskId == subA).State);
|
||||||
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State);
|
Assert.Equal(WorktreeState.Active, ctx.Worktrees.Single(w => w.TaskId == subB).State);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(string parentId, string subA, string subB)> SeedCancelledParentWithDoneChildrenAsync(
|
private async Task<(string parentId, string subA, string subB)> SeedCancelledParentWithDoneChildrenAsync(
|
||||||
@@ -677,4 +686,196 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
|||||||
|
|
||||||
return (parentId, subA, subB);
|
return (parentId, subA, subB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Unit-merge failure propagation (blocked/verify_failed/untracked_collision) ─────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A child whose branch has no common history with the target branch makes the underlying
|
||||||
|
/// `git merge --no-ff` refuse outright (no conflict markers at all) — TaskMergeService reports
|
||||||
|
/// this as StatusBlocked, not StatusConflict. Before this fix that outcome vanished: DrainAsync
|
||||||
|
/// only logged it server-side and broadcast a bare PlanningMergeAborted, and ApproveReview
|
||||||
|
/// always returned StatusMerged regardless. Now StartAsync must surface the real status/reason,
|
||||||
|
/// and the broadcast must carry that reason.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_ChildMergeBlocked_ReturnsBlockedResultAndBroadcastsReason()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, _) = await SeedImprovementParentWithOneUnrelatedHistoryChildAsync(db, repo);
|
||||||
|
|
||||||
|
var (orch, spy) = BuildOrchestrator(db);
|
||||||
|
|
||||||
|
var result = await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result.Reason));
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
// The parent was never finalized — it stays wherever it was (WaitingForReview here).
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, ctx.Tasks.Single(t => t.Id == parentId).Status);
|
||||||
|
|
||||||
|
var abortedCall = Assert.Single(spy, c => c.Method == "PlanningMergeAborted");
|
||||||
|
Assert.Equal(parentId, (string)abortedCall.Args[0]!);
|
||||||
|
Assert.Equal(result.Reason, (string?)abortedCall.Args[1]);
|
||||||
|
Assert.DoesNotContain(spy, c => c.Method == "PlanningCompleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string parentId, string subA)> SeedImprovementParentWithOneUnrelatedHistoryChildAsync(
|
||||||
|
DbFixture db, GitRepoFixture repo)
|
||||||
|
{
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "test", CreatedAt = DateTime.UtcNow,
|
||||||
|
WorkingDir = repo.RepoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = listId, Title = "improve", CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.None, SortOrder = 0,
|
||||||
|
Number = ++_numberSeed,
|
||||||
|
});
|
||||||
|
|
||||||
|
var subA = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subA, ListId = listId, Title = "child A", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1,
|
||||||
|
Number = ++_numberSeed,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
SeedWorktreeUnrelatedHistory(ctx, repo, subA, "fileA.txt", "content A");
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (parentId, subA);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a worktree on a branch with no common ancestor with the target branch, so
|
||||||
|
/// `git merge --no-ff` refuses with "refusing to merge unrelated histories" — a real,
|
||||||
|
/// deterministic StatusBlocked trigger with no conflict files, as opposed to the
|
||||||
|
/// files.Count > 0 path the other fixtures exercise.
|
||||||
|
/// </summary>
|
||||||
|
private void SeedWorktreeUnrelatedHistory(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content)
|
||||||
|
{
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
var branch = $"claudedo/{taskId[..8]}";
|
||||||
|
const string emptyTreeSha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||||
|
var orphanRoot = GitRepoFixture.RunGit(repo.RepoDir, "commit-tree", emptyTreeSha, "-m", "orphan root").Trim();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", branch, orphanRoot);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", wtPath, branch);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, filename), content);
|
||||||
|
GitRepoFixture.RunGit(wtPath, "add", filename);
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"add {filename}");
|
||||||
|
var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim();
|
||||||
|
|
||||||
|
ctx.Worktrees.Add(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskId,
|
||||||
|
Path = wtPath,
|
||||||
|
BranchName = branch,
|
||||||
|
BaseCommit = orphanRoot,
|
||||||
|
HeadCommit = head,
|
||||||
|
DiffStat = null,
|
||||||
|
State = WorktreeState.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Guard (b): HasActiveMerge must span the finalize window ─────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Once the last child has merged, DrainAsync clears CurrentSubtaskId before calling
|
||||||
|
/// FinalizeParentDoneAsync (which flips the parent to Done). Before this fix, HasActiveMerge
|
||||||
|
/// went false in that window, so TaskStateService.CancelAsync's "a merge is in progress"
|
||||||
|
/// guard stopped protecting the parent for the whole duration of the finalize call. This test
|
||||||
|
/// observes HasActiveMerge from inside ApproveReviewAsync (the call FinalizeParentDoneAsync
|
||||||
|
/// makes) to prove the window is now covered, and that the flag clears once the drain returns.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Drain_FinalizeWindow_HasActiveMergeStaysTrueUntilFinalizeCompletes()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, _, _) = await SeedImprovementParentWithTwoDoneChildrenAsync(db, repo);
|
||||||
|
|
||||||
|
var fakeHub = new OrchestratorFakeHubContext();
|
||||||
|
var broadcaster = new HubBroadcaster(fakeHub);
|
||||||
|
var git = new GitService();
|
||||||
|
var factory = db.CreateFactory();
|
||||||
|
var built = TaskStateServiceBuilder.Build(factory);
|
||||||
|
var merge = new TaskMergeService(
|
||||||
|
factory, git, broadcaster, built.State, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||||
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
||||||
|
|
||||||
|
PlanningMergeOrchestrator? orchRef = null;
|
||||||
|
bool? activeDuringApprove = null;
|
||||||
|
var observingState = new ApproveObservingTaskStateService(built.State, () =>
|
||||||
|
{
|
||||||
|
activeDuringApprove = orchRef!.HasActiveMerge(parentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
var orch = new PlanningMergeOrchestrator(
|
||||||
|
factory, merge, aggregator, broadcaster, git, observingState, NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||||
|
orchRef = orch;
|
||||||
|
|
||||||
|
var result = await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.True(activeDuringApprove, "HasActiveMerge must still be true while FinalizeParentDoneAsync's ApproveReviewAsync runs.");
|
||||||
|
Assert.False(orch.HasActiveMerge(parentId), "state must be cleared once the drain (incl. finalize) fully completes.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test-only decorator that invokes a callback right before delegating
|
||||||
|
/// ApproveReviewAsync — everything else passes straight through to the real service.</summary>
|
||||||
|
file sealed class ApproveObservingTaskStateService : ITaskStateService
|
||||||
|
{
|
||||||
|
private readonly ITaskStateService _inner;
|
||||||
|
private readonly Action _onApprove;
|
||||||
|
|
||||||
|
public ApproveObservingTaskStateService(ITaskStateService inner, Action onApprove)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_onApprove = onApprove;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_onApprove();
|
||||||
|
return _inner.ApproveReviewAsync(taskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct) => _inner.EnqueueAsync(taskId, ct);
|
||||||
|
public Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct) => _inner.StartRunningAsync(taskId, startedAt, ct);
|
||||||
|
public Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct) => _inner.CompleteAsync(taskId, finishedAt, result, ct);
|
||||||
|
public Task<TransitionResult> SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct) => _inner.SubmitForReviewAsync(taskId, finishedAt, result, ct);
|
||||||
|
public Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct) => _inner.SubmitInteractiveForReviewAsync(taskId, finishedAt, ct);
|
||||||
|
public Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct) => _inner.SubmitForChildrenAsync(taskId, finishedAt, result, ct);
|
||||||
|
public Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct, string failureReason = "error", int? turnsUsed = null, int? maxTurns = null)
|
||||||
|
=> _inner.FailAsync(taskId, finishedAt, error, ct, failureReason, turnsUsed, maxTurns);
|
||||||
|
public Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false) => _inner.CancelAsync(taskId, finishedAt, ct, allowFromIdle);
|
||||||
|
public Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct) => _inner.ResetToIdleAsync(taskId, ct);
|
||||||
|
public Task<TransitionResult> RejectToQueueAsync(string taskId, string feedback, CancellationToken ct) => _inner.RejectToQueueAsync(taskId, feedback, ct);
|
||||||
|
public Task<TransitionResult> RejectToIdleAsync(string taskId, CancellationToken ct) => _inner.RejectToIdleAsync(taskId, ct);
|
||||||
|
public Task<TransitionResult> ClearReviewFeedbackAsync(string taskId, CancellationToken ct) => _inner.ClearReviewFeedbackAsync(taskId, ct);
|
||||||
|
public Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct) => _inner.ForceSetStatusAsync(taskId, status, ct);
|
||||||
|
public Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct) => _inner.StartPlanningAsync(parentId, ct);
|
||||||
|
public Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct) => _inner.FinalizePlanningAsync(parentId, ct);
|
||||||
|
public Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct) => _inner.BlockOnAsync(taskId, predecessorTaskId, ct);
|
||||||
|
public Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct) => _inner.UnblockAsync(taskId, ct);
|
||||||
|
public Task<TransitionResult> SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct) => _inner.SetDependsOnAsync(taskId, dependsOnTaskId, ct);
|
||||||
|
public Task TryAdvanceParentAsync(string parentId) => _inner.TryAdvanceParentAsync(parentId);
|
||||||
|
public Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct) => _inner.RecoverStaleRunningAsync(reason, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||||
public event Action<string>? PlanningMergeAbortedEvent;
|
public event Action<string, string?>? PlanningMergeAbortedEvent;
|
||||||
public event Action<string>? PlanningCompletedEvent;
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||||
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||||
|
|||||||
Reference in New Issue
Block a user