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
@@ -0,0 +1,60 @@
using ClaudeDo.Worker.Lifecycle;
namespace ClaudeDo.Worker.Tests.Services;
public class VerifyCommandRunnerTests
{
private readonly VerifyCommandRunner _runner = new();
[Fact]
public async Task RunAsync_ExitsZero_ReportsSuccessNotTimedOut()
{
var result = await _runner.RunAsync(
Path.GetTempPath(), "exit 0", TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Equal(0, result.ExitCode);
Assert.False(result.TimedOut);
}
[Fact]
public async Task RunAsync_NonZeroExit_ReportsExitCode()
{
var result = await _runner.RunAsync(
Path.GetTempPath(), "exit 7", TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Equal(7, result.ExitCode);
Assert.False(result.TimedOut);
}
[Fact]
public async Task RunAsync_CapturesStdoutAndStderr()
{
var result = await _runner.RunAsync(
Path.GetTempPath(), "echo hello-out & echo hello-err 1>&2", TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Contains("hello-out", result.Output);
Assert.Contains("hello-err", result.Output);
}
[Fact]
public async Task RunAsync_RunsInSpecifiedWorkingDirectory()
{
var dir = Path.Combine(Path.GetTempPath(), $"verify_wd_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var result = await _runner.RunAsync(dir, "cd", TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Contains(new DirectoryInfo(dir).Name, result.Output);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public async Task RunAsync_ExceedsTimeout_KillsProcessAndReportsTimedOut()
{
var result = await _runner.RunAsync(
Path.GetTempPath(), "ping -n 60 127.0.0.1", TimeSpan.FromMilliseconds(300), CancellationToken.None);
Assert.True(result.TimedOut);
}
}