fix(worker): kill cancelled runs' processes and make MCP approve actually merge
- CancelAsync now signals the running Claude process of the cancelled task and its cascaded children via the new RunCancellationRegistry (queue + override slots register their CTS there) instead of only flipping DB state. - external MCP review_task 'approve' now mirrors the hub's ApproveReview: unit merge for parents, ApproveAndMergeAsync for childless tasks, optional targetBranch; ReviewTaskResult carries mergeStatus/conflicts.
This commit is contained in:
@@ -10,6 +10,7 @@ public sealed class OverrideSlotService
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly TaskRunner _runner;
|
||||
private readonly ILogger<OverrideSlotService> _logger;
|
||||
private readonly RunCancellationRegistry _runCancels;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private volatile QueueSlotState? _slot;
|
||||
@@ -17,11 +18,13 @@ public sealed class OverrideSlotService
|
||||
public OverrideSlotService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
TaskRunner runner,
|
||||
ILogger<OverrideSlotService> logger)
|
||||
ILogger<OverrideSlotService> logger,
|
||||
RunCancellationRegistry runCancels)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_runner = runner;
|
||||
_logger = logger;
|
||||
_runCancels = runCancels;
|
||||
}
|
||||
|
||||
public QueueSlotState? CurrentSlot => _slot;
|
||||
@@ -66,12 +69,14 @@ public sealed class OverrideSlotService
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_slot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
_runCancels.Register(taskId, cts);
|
||||
|
||||
_ = work(cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, faultMessage, taskId);
|
||||
lock (_lock) { _slot = null; }
|
||||
_runCancels.Unregister(taskId, cts);
|
||||
cts.Dispose();
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class QueueService : BackgroundService
|
||||
private readonly IQueuePicker _picker;
|
||||
private readonly OverrideSlotService _override;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly RunCancellationRegistry _runCancels;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, QueueSlotState> _queueSlots = new();
|
||||
@@ -31,7 +32,8 @@ public sealed class QueueService : BackgroundService
|
||||
QueueWaker waker,
|
||||
IQueuePicker picker,
|
||||
OverrideSlotService overrideSlot,
|
||||
ITaskStateService state)
|
||||
ITaskStateService state,
|
||||
RunCancellationRegistry runCancels)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_runner = runner;
|
||||
@@ -41,6 +43,7 @@ public sealed class QueueService : BackgroundService
|
||||
_picker = picker;
|
||||
_override = overrideSlot;
|
||||
_state = state;
|
||||
_runCancels = runCancels;
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive()
|
||||
@@ -125,12 +128,14 @@ public sealed class QueueService : BackgroundService
|
||||
{
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
_queueSlots[task.Id] = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
_runCancels.Register(task.Id, cts);
|
||||
|
||||
_ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
|
||||
lock (_lock) { _queueSlots.Remove(task.Id); }
|
||||
_runCancels.Unregister(task.Id, cts);
|
||||
cts.Dispose();
|
||||
_waker.Wake(); // Check for next task immediately.
|
||||
}, TaskScheduler.Default);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
/// Maps a running task id to the CancellationTokenSource driving its Claude process.
|
||||
/// QueueService and OverrideSlotService register their slots here so components that
|
||||
/// cancel tasks DB-side (TaskStateService.CancelAsync and its child cascade) can also
|
||||
/// stop the process — without depending on the queue services, which would create a
|
||||
/// DI cycle (QueueService → TaskRunner → ITaskStateService).
|
||||
public sealed class RunCancellationRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _running = new(StringComparer.Ordinal);
|
||||
|
||||
public void Register(string taskId, CancellationTokenSource cts) => _running[taskId] = cts;
|
||||
|
||||
/// Removes the registration only if <paramref name="cts"/> is still the registered
|
||||
/// one — a re-run may already have registered a newer CTS under the same task id.
|
||||
public void Unregister(string taskId, CancellationTokenSource cts) =>
|
||||
_running.TryRemove(new KeyValuePair<string, CancellationTokenSource>(taskId, cts));
|
||||
|
||||
public bool TryCancel(string taskId)
|
||||
{
|
||||
if (!_running.TryGetValue(taskId, out var cts)) return false;
|
||||
try
|
||||
{
|
||||
cts.Cancel();
|
||||
return true;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Slot cleanup raced us; the run is already finished.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user