feat(worker): add post-merge verification gate for list merges

Per-list optional VerifyCommand (list_config.verify_command) runs via
VerifyCommandRunner in the list's working dir right after a successful
merge/continue-merge, before the task is allowed to reach Done. A
non-zero exit or timeout leaves the merge in place but keeps the task
out of Done and reports StatusVerifyFailed with an output excerpt
through MergeResultDto/review_task; no command configured behaves
exactly as before. Merges against the same repo are now serialized
per working dir so a running verify can't be interrupted by a second
merge landing mid-build. Adds the field to the List Settings modal
(en/de localized) and covers success/failure/timeout in
TaskMergeServiceTests + VerifyCommandRunnerTests.
This commit is contained in:
mika kuns
2026-08-05 11:19:22 +02:00
parent 334cf1e1d2
commit c9ba1e2645
30 changed files with 1469 additions and 126 deletions
@@ -184,7 +184,10 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { }
}
public async System.Threading.Tasks.Task SaveAsync()
// verifyCommand is a List-only field owned by ListSettingsModalViewModel (not this editor,
// which is also reused for Task scope); the caller passes it through so the single
// UpdateListConfig call carries the full desired row instead of clobbering it.
public async System.Threading.Tasks.Task SaveAsync(string? verifyCommand = null)
{
if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -196,7 +199,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills));
else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills));
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand));
}
private List<string>? SelectedSessionSkillNames()
@@ -1073,6 +1073,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
if (!hasChildren && result?.Status == "conflict")
await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
// The merge itself already landed; the verify command failed, so the task stayed
// out of Done. Surface that instead of silently looking like nothing happened.
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
}
catch (Exception ex)
{
@@ -30,6 +30,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
[ObservableProperty] private bool _isManual;
// Optional post-merge verification command (build/test), run in WorkingDir after a merge
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = "";
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
@@ -61,6 +64,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
await Agent.LoadForListAsync(listId, ct);
var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? "";
}
[RelayCommand]
@@ -73,7 +78,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
DefaultCommitType,
IsManual));
await Agent.SaveAsync();
await Agent.SaveAsync(string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand);
CloseAction?.Invoke();
}