refactor(worker): drop ForceSetStatusAsync, route every status write through a guarded transition

Mark-done now fires OnChildTerminalAsync, so ticking a chain child or the last open child
off by hand unblocks the successor and advances the parent. The hub's Mark-as menu and
update_task_status map onto MarkDone/Cancel(retire)/Enqueue/Reset; revert_merge uses the
new ReopenForReviewAsync (Done|WaitingForReview -> WaitingForReview).
This commit is contained in:
mika kuns
2026-10-02 16:03:59 +02:00
parent 59d990c267
commit ff7197c410
9 changed files with 113 additions and 61 deletions
@@ -895,12 +895,12 @@ file sealed class ApproveObservingTaskStateService : ITaskStateService
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> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct, bool retire = false) => _inner.CancelAsync(taskId, finishedAt, ct, retire);
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> ReopenForReviewAsync(string taskId, CancellationToken ct) => _inner.ReopenForReviewAsync(taskId, ct);
public Task<TransitionResult> MarkDoneAsync(string taskId, DateTime finishedAt, CancellationToken ct) => _inner.MarkDoneAsync(taskId, finishedAt, ct);
public Task<TransitionResult> UnmarkDoneAsync(string taskId, CancellationToken ct) => _inner.UnmarkDoneAsync(taskId, ct);
public Task<TransitionResult> DequeueToIdleAsync(string taskId, CancellationToken ct) => _inner.DequeueToIdleAsync(taskId, ct);
@@ -534,6 +534,58 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Equal(TaskStatus.Done, await GetStatusAsync(id));
}
[Fact]
public async Task MarkDoneAsync_OnChainChild_UnblocksSuccessor()
{
var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized);
var c0 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 0);
var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0);
await _sut.MarkDoneAsync(c0, DateTime.UtcNow, default);
Assert.Null((await GetTaskAsync(c1)).BlockedByTaskId);
}
[Fact]
public async Task MarkDoneAsync_OnLastOpenChild_AdvancesParentToReview()
{
var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized);
await SeedTaskAsync(TaskStatus.Done, parentId: parent);
var open = await SeedTaskAsync(TaskStatus.Failed, parentId: parent);
await _sut.MarkDoneAsync(open, DateTime.UtcNow, default);
Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent));
}
[Theory]
[InlineData(TaskStatus.Done, true)]
[InlineData(TaskStatus.WaitingForReview, true)]
[InlineData(TaskStatus.Idle, false)]
[InlineData(TaskStatus.Running, false)]
public async Task ReopenForReviewAsync_OnlyFromDoneOrReview(TaskStatus from, bool ok)
{
var id = await SeedTaskAsync(from);
var result = await _sut.ReopenForReviewAsync(id, default);
Assert.Equal(ok, result.Ok);
Assert.Equal(ok ? TaskStatus.WaitingForReview : from, await GetStatusAsync(id));
}
[Theory]
[InlineData(TaskStatus.Idle)]
[InlineData(TaskStatus.Failed)]
public async Task CancelAsync_Retire_AllowsIdleAndFailed(TaskStatus from)
{
var id = await SeedTaskAsync(from);
var result = await _sut.CancelAsync(id, DateTime.UtcNow, default, retire: true);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(id));
}
[Fact]
public async Task UnmarkDoneAsync_FromDone_TransitionsToIdle()
{