fix(worker): close status-force bypasses on Running tasks
update_task_status Idle/Done, WorkerHub.SetTaskStatus, and the UI's Mark Done/Cancelled menu items could all force a Running task's status without going through TaskStateService's guards, leaving its CLI process untracked and its worktree writes silently dropped. Idle now routes through the already-guarded TaskStateService.ResetToIdleAsync instead of the repo's unconditional ResetToManualAsync; Done and SetTaskStatus explicitly reject Running; the context menu disables both entries with a tooltip while running. TaskRunner also now logs a warning whenever a terminal transition (SubmitForChildren/SubmitForReview/Complete/Fail) is rejected instead of silently discarding the run result. DeleteTask's MCP path gets the same friendly foreign-key message WorkerHub.DeleteTask already had for a task with children.
This commit is contained in:
+24
-5
@@ -592,8 +592,9 @@ public sealed class ExternalMcpService
|
||||
string taskId,
|
||||
[Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " +
|
||||
"deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " +
|
||||
"active worktree — use review_task to approve and merge that worktree instead). No other " +
|
||||
"value is settable externally.")]
|
||||
"active worktree — use review_task to approve and merge that worktree instead). 'Idle' and " +
|
||||
"'Done' are both refused while the task is Running — cancel it first. No other value is " +
|
||||
"settable externally.")]
|
||||
string status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -609,8 +610,9 @@ public sealed class ExternalMcpService
|
||||
switch (target)
|
||||
{
|
||||
case TaskStatus.Idle:
|
||||
await _tasks.ResetToManualAsync(taskId, cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
var idleResult = await _state.ResetToIdleAsync(taskId, cancellationToken);
|
||||
if (!idleResult.Ok)
|
||||
throw new InvalidOperationException(idleResult.Reason ?? "Cannot reset task to Idle.");
|
||||
break;
|
||||
|
||||
case TaskStatus.Queued:
|
||||
@@ -627,6 +629,9 @@ public sealed class ExternalMcpService
|
||||
break;
|
||||
|
||||
case TaskStatus.Done:
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot mark a running task Done — cancel it first.");
|
||||
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
|
||||
{
|
||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken);
|
||||
@@ -870,7 +875,21 @@ public sealed class ExternalMcpService
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
try
|
||||
{
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
}
|
||||
// TaskRepository.DeleteAsync uses ExecuteDeleteAsync, which bypasses SaveChanges and
|
||||
// surfaces provider errors directly as SqliteException rather than DbUpdateException.
|
||||
catch (Exception ex) when (
|
||||
(ex is Microsoft.Data.Sqlite.SqliteException || ex.InnerException is Microsoft.Data.Sqlite.SqliteException)
|
||||
&& (ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||||
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"This task has child tasks. Discard the planning session or delete child tasks first.");
|
||||
}
|
||||
|
||||
if (task.ParentTaskId is not null)
|
||||
await _state.TryAdvanceParentAsync(task.ParentTaskId);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
|
||||
Reference in New Issue
Block a user