fix(worker): depends_on-Nachfolger schon beim gelandeten Merge freigeben

Der Queue-Picker gatete eine task_dependencies-Kante auf status='done'.
review_task(skipVerify) laesst einen erfolgreich gemergten Task aber
bewusst auf WaitingForReview stehen, bis verify_merges einmal zentral
laeuft -- damit blockierte jeder Nachfolger ueber den gesamten Batch plus
Full-Suite-Verify hinweg, bei freien Slots.

Eine Kante gilt jetzt als erfuellt, wenn der Vorgaenger 'done' ist ODER
sein Merge gelandet ist (worktrees.state = 'merged'). TaskBlocking
(get_task/list_tasks/wait_for_task_change) und der UI-Wartehinweis folgen
derselben Regel, damit "blocked" nicht meldet, was der Picker claimt.

Abgeleitet: revert_merge/abort_merge setzen den Worktree auf Active und
blockieren wieder; reject_rerun/reject_park haben nie gemergt und bleiben
unerfuellt; ein Task ohne Worktree wird nur ueber 'done' erfuellt; ein
fehlgeschlagener verify_merges gibt Nachfolger frei, weil der Code so
oder so auf main liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-09-16 15:43:37 +02:00
committed by mika kuns
co-authored by Claude Opus 5
parent 50b6b56f87
commit c874bf7e52
8 changed files with 159 additions and 31 deletions
@@ -138,6 +138,74 @@ public sealed class QueuePickerTests : IDisposable
Assert.Equal(dependent.Id, picked!.Id);
}
private async Task SeedWorktreeAsync(string taskId, WorktreeState state)
{
_ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId,
Path = $"/tmp/{taskId}",
BranchName = $"claudedo/{taskId}",
BaseCommit = "abc123",
State = state,
CreatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
}
[Fact]
public async Task ClaimNextAsync_Claims_TaskWhoseDependsOnIsMergedButAwaitingBatchVerify()
{
// review_task(skipVerify: true) leaves a successfully merged task in WaitingForReview
// until verify_merges runs once for the whole batch. Gating on Done alone stalled every
// successor for the entire batch, so a landed merge satisfies the edge.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Merged);
var dependent = await SeedAsync(listId, dependsOn: [dependency.Id]);
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(dependent.Id, picked!.Id);
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnWasRejected()
{
// reject_rerun/reject_park never merge, so the worktree stays Active -- the successor
// must keep waiting even though the predecessor left WaitingForReview.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.Idle);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Active);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnMergeWasReverted()
{
// revert_merge / abort_merge put the worktree back to Active -- the edge is unmet again.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Active);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseWorktreelessDependsOnIsNotDone()
{
// A handler task or an empty-diff run has no worktrees row at all: only 'done' can
// satisfy that edge, and the merged-branch must not accidentally match a missing row.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnFailed_ButKeepsClaimingOthers()
{