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
+30 -37
View File
@@ -297,13 +297,13 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null);
}
// allowFromIdle: only the external update_task_status(Cancelled) path sets this — it lets
// an Idle task be retired without deleting it. Every other caller (hub CancelReview,
// PlanningChainCoordinator's chain-walk, batch cancel) relies on Idle staying a no-op here;
// PlanningChainCoordinator specifically uses "parked back to Idle" as a deliberate opt-out
// signal, so do not flip this default.
// retire: only the user-facing "mark cancelled" paths (update_task_status, the row's
// "Mark as" menu) set this — it lets an Idle or Failed task be retired without deleting it.
// Every other caller (hub CancelReview, PlanningChainCoordinator's chain-walk, batch cancel)
// relies on Idle staying a no-op here; PlanningChainCoordinator specifically uses "parked
// back to Idle" as a deliberate opt-out signal, so do not flip this default.
public async Task<TransitionResult> CancelAsync(
string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false)
string taskId, DateTime finishedAt, CancellationToken ct, bool retire = false)
{
// A unit merge drains its children's worktrees onto the target branch while the parent
// sits in WaitingForReview for the whole (potentially minutes-long) drain. Cancelling out
@@ -321,7 +321,7 @@ public sealed class TaskStateService : ITaskStateService
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued
|| t.Status == TaskStatus.WaitingForReview
|| t.Status == TaskStatus.WaitingForChildren
|| (allowFromIdle && t.Status == TaskStatus.Idle)))
|| (retire && (t.Status == TaskStatus.Idle || t.Status == TaskStatus.Failed))))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Cancelled)
.SetProperty(t => t.FinishedAt, finishedAt), ct);
@@ -381,49 +381,42 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null);
}
// Unconditional status write — bypasses transition rules. Used by the UI's
// "set status freely" affordance; intentionally no guards (caller may strand
// the runner if used while a task is executing). It also bypasses chain/parent
// advancement — forcing a chain child or a WaitingForChildren parent's child to a
// terminal status here does not unblock its successor or re-check the parent.
public async Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct)
/// <summary>Done/WaitingForReview → WaitingForReview after a reverted merge, so the task can be re-approved.</summary>
public async Task<TransitionResult> ReopenForReviewAsync(string taskId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = status == TaskStatus.Done
? await ctx.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, status)
.SetProperty(t => t.FinishedAt, DateTime.UtcNow), ct)
: await ctx.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status), ct);
var affected = await ctx.Tasks
.Where(t => t.Id == taskId &&
(t.Status == TaskStatus.Done || t.Status == TaskStatus.WaitingForReview))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.WaitingForReview), ct);
if (affected == 0)
return new TransitionResult(false, "Task not found.");
return new TransitionResult(false, "Task is not Done or waiting for review; cannot reopen.");
if (status == TaskStatus.Queued) _waker.Wake();
await NotifyAsync(taskId);
return new TransitionResult(true, null);
}
public async Task<TransitionResult> MarkDoneAsync(string taskId, DateTime finishedAt, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// The done toggle is a manual "tick it off" affordance and works from every status
// except Running (a live run keeps writing the row, so the picker/runner would race).
// WaitingForReview is deliberately included: ticking a finished task done skips the
// merge on purpose -- for a worktree-less list-handler task there is nothing to merge,
// and for a worktree task the branch just stays Active for the worktrees overview.
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Done)
.SetProperty(t => t.FinishedAt, finishedAt), ct);
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{
// The done toggle is a manual "tick it off" affordance and works from every status
// except Running (a live run keeps writing the row, so the picker/runner would race).
// WaitingForReview is deliberately included: ticking a finished task done skips the
// merge on purpose -- for a worktree-less list-handler task there is nothing to merge,
// and for a worktree task the branch just stays Active for the worktrees overview.
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Done)
.SetProperty(t => t.FinishedAt, finishedAt), ct);
if (affected == 0)
return new TransitionResult(false, "Task is running or no longer exists; cannot mark done.");
if (affected == 0)
return new TransitionResult(false, "Task is running or no longer exists; cannot mark done.");
}
await OnChildTerminalAsync(taskId, TaskStatus.Done);
await NotifyAsync(taskId);
return new TransitionResult(true, null);
}