feat(mission-control): Handler-Run per Knopf abbrechen

Ein "Abandon run"-Knopf an Handler-Panes fragt HandlerRoundState.Get(handlerTaskId)
nach den noch offenen (Queued/Running) Tasks des Laufs, bestaetigt mit Anzahl,
cancelt jeden ueber den bestehenden CancelTaskAsync-Pfad und schliesst danach die
Session -- ein Schritt statt Tile schliessen + Queue separat abbrechen. Ohne
offene Tasks (oder ohne Round-Eintrag) schliesst die Aktion direkt, ohne Dialog.
Nur an den beiden Handler-Panes sichtbar (IsListHandler), normale Task-/Ad-hoc-
Panes bleiben unveraendert.
This commit is contained in:
mika kuns
2026-09-02 22:39:12 +02:00
parent b298594a2c
commit bd5aa265d1
13 changed files with 417 additions and 5 deletions
+4
View File
@@ -217,6 +217,10 @@ public sealed record LaunchSpec(
IReadOnlyList<string> Args,
IReadOnlyDictionary<string, string> Env);
// A handler round's still-open (Queued/Running) task, for the Mission Control "abandon run"
// action -- confirms with a count, then cancels each one before closing the pane.
public record HandlerOpenTaskDto(string Id, string Title);
public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet);
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
public record TicketProjectDto(int Id, string Title, string DepartmentName);
@@ -354,6 +354,10 @@
"enqueueAlreadyOpen": "Kann nicht in die Warteschlange gestellt werden — dieser Task hat eine offene interaktive Sitzung.",
"enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}",
"queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {0}",
"abandonRun": "Lauf abbrechen",
"abandonRunTip": "Die noch offenen Tasks dieses Laufs abbrechen und die Sitzung schließen",
"abandonRunConfirm": "{0} offene(n) Task(s) abbrechen und diese Sitzung schließen? Tasks im Review oder bereits erledigt bleiben unangetastet.",
"abandonRunFailed": "Offene Tasks konnten nicht abgebrochen werden: {0}",
"question": {
"title": "Claude fragt nach",
"placeholder": "Antwort eingeben…",
@@ -354,6 +354,10 @@
"enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.",
"enqueueFailed": "Queue failed: {0}",
"queueRefreshFailed": "Couldn't refresh the queue: {0}",
"abandonRun": "Abandon run",
"abandonRunTip": "Cancel this run's still-open tasks and close the session",
"abandonRunConfirm": "Cancel {0} open task(s) and close this session? Tasks already in review or done are not affected.",
"abandonRunFailed": "Couldn't cancel the open tasks: {0}",
"question": {
"title": "Claude is asking",
"placeholder": "Type your answer…",
@@ -158,6 +158,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// running. The exit code only exists in this process; the worker decides whether it was a
/// list-handler session killed by the usage limit and reopens the same phase after the 5h reset.</summary>
Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default);
/// <summary>The handler round's still-open (Queued/Running) tasks, for the Mission Control
/// "abandon run" action. Empty when the round has no entry or nothing open -- the caller then
/// closes the pane without a confirmation dialog.</summary>
Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
+3
View File
@@ -641,6 +641,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
=> InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct));
public async Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> await InvokeTimedAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", () => _hub.InvokeAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", handlerTaskId, ct));
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
@@ -18,6 +18,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
public bool IsTaskBased => TaskId is not null;
/// <summary>Set by the host for a list-handler run ("Let Claude handle it" / "Let Claude merge
/// the reviews") pane -- shows the "Abandon run" action. Plain mutable property, not
/// <see cref="ObservableProperty"/>: the host sets it once right after construction, before the
/// pane is added to the visible collection, so no change notification is needed.</summary>
public bool IsListHandler { get; set; }
[ObservableProperty] private string _displayTitle;
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
@@ -26,6 +32,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
[ObservableProperty] private bool _isSubmitPending;
/// <summary>Set by the host while an abandon-run round trip (query open tasks, confirm,
/// cancel each, close pane) is in flight, so a rapid double-click can't race two runs.</summary>
[ObservableProperty] private bool _isAbandonPending;
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
public event Action<string>? ErrorReported;
@@ -36,6 +46,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested;
/// <summary>Raised when the user abandons a list-handler run (<see cref="IsListHandler"/>
/// only): the host looks up this round's still-open tasks, confirms with the user (naming the
/// count), cancels them, then closes this pane -- one action instead of closing the tile and
/// separately cancelling the queue.</summary>
public event Action<string>? AbandonRunRequested;
/// <summary>Raised with <c>(taskId, exitCode)</c> when a task-based session that had been running
/// dies non-zero. The host reports it to the worker, which decides whether this was a list
/// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is
@@ -141,6 +157,16 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged();
private bool CanAbandonRun() => IsListHandler && !IsAbandonPending;
[RelayCommand(CanExecute = nameof(CanAbandonRun))]
private void AbandonRun()
{
if (TaskId is { } id) AbandonRunRequested?.Invoke(id);
}
partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged();
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
// re-opening the same task would otherwise just re-focus a dead tile.
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
@@ -79,6 +79,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
// View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the
// view the same way TasksIslandViewModel/DetailsIslandViewModel are.
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
@@ -375,7 +379,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => launchSpec(taskId))));
() => DescribeAsync(() => launchSpec(taskId))) { IsListHandler = true });
}
finally
{
@@ -416,7 +420,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch { /* best-effort title lookup */ }
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
{ IsListHandler = true });
}
// The 5h usage window is about to run out under this handler session: type the worker's warning
@@ -440,6 +445,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied;
pane.AbandonRunRequested += OnPaneAbandonRun;
ConPtySessions.Add(pane);
pane.Start();
}
@@ -495,6 +501,41 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
}
// Handler-run abort ("Not-Aus"): cancels every still-open (Queued/Running) task this handler
// round queued or started, then closes the pane -- one action instead of "close tile" + "cancel
// the tasks separately", which used to leave orphaned rows nobody merges. No open tasks (or no
// round entry at all -- 7-day retention, or a run predating this feature) closes the pane
// straight away, no confirmation needed. Guarded by IsAbandonPending like OnPaneSubmitForReview.
private async void OnPaneAbandonRun(string taskId)
{
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsAbandonPending)
return;
pane.IsAbandonPending = true;
try
{
var open = await _worker.GetHandlerOpenTasksAsync(taskId);
if (open.Count > 0)
{
if (ConfirmAsync is null) return;
if (!await ConfirmAsync(Loc.T("missionControl.abandonRunConfirm", open.Count))) return;
foreach (var task in open)
await _worker.CancelTaskAsync(task.Id);
}
CloseConPtySession(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.abandonRunFailed", ex.Message));
}
finally
{
pane.IsAbandonPending = false;
}
}
private void CloseConPtySession(ConPtyPaneViewModel pane)
{
if (!ConPtySessions.Contains(pane)) return;
@@ -502,6 +543,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.CloseRequested -= CloseConPtySession;
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
pane.AbandonRunRequested -= OnPaneAbandonRun;
ConPtySessions.Remove(pane);
pane.Dispose();
}
@@ -555,6 +597,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
c.CloseRequested -= CloseConPtySession;
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
c.SessionDiedAfterStartup -= OnPaneSessionDied;
c.AbandonRunRequested -= OnPaneAbandonRun;
c.Dispose();
}
ConPtySessions.Clear();
@@ -14,7 +14,7 @@
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1" Padding="8,3">
<Grid ColumnDefinitions="*,Auto,Auto">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding DisplayTitle}"
@@ -25,7 +25,12 @@
Content="{loc:Tr missionControl.submitForReview}"
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
Command="{Binding SubmitForReviewCommand}" />
<Button Grid.Column="2" Classes="title-ctrl"
<Button Grid.Column="2" Classes="btn" Margin="0,0,6,0"
IsVisible="{Binding IsListHandler}"
Content="{loc:Tr missionControl.abandonRun}"
ToolTip.Tip="{loc:Tr missionControl.abandonRunTip}"
Command="{Binding AbandonRunCommand}" />
<Button Grid.Column="3" Classes="title-ctrl"
Command="{Binding CloseCommand}"
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
@@ -2,6 +2,7 @@ using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
@@ -55,6 +56,7 @@ public partial class MissionControlView : UserControl
_vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null;
_vm.ConfirmAsync = null;
}
_vm = DataContext as MissionControlViewModel;
@@ -64,12 +66,59 @@ public partial class MissionControlView : UserControl
_vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
_vm.ConfirmAsync = ShowConfirmAsync;
}
RebuildOverviewGrid();
UpdateQueueColumn();
}
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
private async Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildOverviewGrid();
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+22 -1
View File
@@ -73,6 +73,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly TicketClientFactory _ticketClients;
private readonly TicketImportService _ticketImport;
private readonly HandlerRestartCoordinator? _handlerRestart;
private readonly HandlerRoundState? _roundState;
public WorkerHub(
QueueService queue,
@@ -112,7 +113,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
TicketPatStore ticketPat = null!,
TicketClientFactory ticketClients = null!,
TicketImportService ticketImport = null!,
HandlerRestartCoordinator? handlerRestart = null)
HandlerRestartCoordinator? handlerRestart = null,
HandlerRoundState? roundState = null)
{
_queue = queue;
_waker = waker;
@@ -152,6 +154,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_ticketClients = ticketClients;
_ticketImport = ticketImport;
_handlerRestart = handlerRestart;
_roundState = roundState;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -810,6 +813,24 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_ = _handlerRestart.OnSessionExitedAsync(taskId, exitCode, CancellationToken.None);
}
// The Mission Control "abandon run" action: what a handler round queued/started that is still
// open (Queued or Running), read from the structured HandlerRoundState store rather than
// parsed out of the handler task's description. Empty when there is no round entry (7-day
// retention, or a run from before this feature) or the round has no open tasks -- the caller
// treats both as "nothing to confirm, just close the pane".
public async Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasks(string handlerTaskId)
{
var round = _roundState?.Get(handlerTaskId);
if (round is not { TaskIds.Count: > 0 }) return Array.Empty<HandlerOpenTaskDto>();
await using var ctx = await _dbFactory.CreateDbContextAsync();
return await ctx.Tasks.AsNoTracking()
.Where(t => round.TaskIds.Contains(t.Id)
&& (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Running))
.Select(t => new HandlerOpenTaskDto(t.Id, t.Title))
.ToListAsync();
}
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
@@ -138,6 +138,8 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
=> Task.CompletedTask;
public virtual Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HandlerOpenTaskDto>>(Array.Empty<HandlerOpenTaskDto>());
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
@@ -966,4 +966,110 @@ public class MissionControlViewModelTests : IDisposable
vm.ToggleLayoutCommand.Execute(null);
Assert.False(vm.IsFocusMode);
}
// ── Abandon run: single-action "stop session + cancel its still-open tasks" ────────────────
private sealed class RecordingAbandonWorker : StubWorkerClient
{
public IReadOnlyList<HandlerOpenTaskDto> OpenTasks { get; set; } = Array.Empty<HandlerOpenTaskDto>();
public readonly List<string> Cancelled = new();
public override Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> Task.FromResult(OpenTasks);
public override Task CancelTaskAsync(string taskId)
{
Cancelled.Add(taskId);
return Task.CompletedTask;
}
}
// A plain OpenConPtySessionAsync pane (not a list handler) never shows/allows the action --
// acceptance criterion 5 ("only at handler panes").
[Fact]
public async Task AbandonRun_OnNonHandlerPane_IsNotAvailable()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
Assert.False(pane.IsListHandler);
Assert.False(pane.AbandonRunCommand.CanExecute(null));
}
[Fact]
public async Task AbandonRun_ListHandlerPane_IsListHandlerTrue_CommandAvailable()
{
var worker = new RecordingAbandonWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var pane = Assert.Single(vm.ConPtySessions);
Assert.True(pane.IsListHandler);
Assert.True(pane.AbandonRunCommand.CanExecute(null));
}
// Acceptance criterion 4: no open tasks (or no round entry, which the fake models the same
// way) closes the pane straight away -- no confirmation dialog.
[Fact]
public async Task AbandonRun_NoOpenTasks_ClosesPaneWithoutConfirming()
{
var worker = new RecordingAbandonWorker { OpenTasks = Array.Empty<HandlerOpenTaskDto>() };
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var pane = Assert.Single(vm.ConPtySessions);
var confirmCalled = false;
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.False(confirmCalled);
Assert.Empty(vm.ConPtySessions);
}
// Acceptance criterion 3 (decline branch): declining leaves the session and its tasks
// untouched.
[Fact]
public async Task AbandonRun_OpenTasks_ConfirmDeclined_LeavesSessionAndTasksUntouched()
{
var worker = new RecordingAbandonWorker
{
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
};
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
var pane = Assert.Single(vm.ConPtySessions);
string? confirmMessage = null;
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.NotNull(confirmMessage);
Assert.Empty(worker.Cancelled);
Assert.Single(vm.ConPtySessions);
}
// Acceptance criteria 1-3 (accept branch): the count is confirmed, every open task is
// cancelled, then the pane closes -- one action instead of two.
[Fact]
public async Task AbandonRun_OpenTasks_ConfirmAccepted_CancelsEachThenClosesPane()
{
var worker = new RecordingAbandonWorker
{
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
};
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
var pane = Assert.Single(vm.ConPtySessions);
vm.ConfirmAsync = _ => Task.FromResult(true);
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
Assert.Empty(vm.ConPtySessions);
}
}
@@ -0,0 +1,141 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Hub;
/// Covers WorkerHub.GetHandlerOpenTasks -- the Mission Control "abandon run" action's read side.
/// The round's TaskIds come from HandlerRoundState (structured store, not the handler task's
/// description); only rows still Queued or Running are returned, so WaitingForReview/Done/Failed/
/// Cancelled/Idle work is never touched by a subsequent cancel loop.
public sealed class HandlerOpenTasksHubTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly string _roundsPath = Path.Combine(Path.GetTempPath(), $"claudedo_rounds_{Guid.NewGuid():N}.json");
private readonly HandlerRoundState _rounds;
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
{
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Array.Empty<SessionSkillEntity>());
}
public HandlerOpenTasksHubTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_rounds = new HandlerRoundState(_roundsPath);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
try { File.Delete(_roundsPath); } catch { /* best effort */ }
}
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var git = new GitService();
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), Microsoft.Extensions.Logging.Abstractions.NullLogger<WorktreeManager>.Instance);
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker(), _rounds);
var built = TaskStateServiceBuilder.Build(factory);
var hub = new WorkerHub(
null!, null!, null!, null!, null!, factory, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git,
roundState: _rounds);
return hub;
}
private async Task<string> SeedListAsync(string name = "L")
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = Path.GetTempPath(), CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status, string title = "T")
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
CreatedAt = DateTime.UtcNow,
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task NoRoundEntry_ReturnsEmpty()
{
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks("no-such-handler-task");
Assert.Empty(result);
}
[Fact]
public async Task RoundWithOnlyTerminalTasks_ReturnsEmpty()
{
var listId = await SeedListAsync();
var done = await SeedTaskAsync(listId, TaskStatus.Done, "Done task");
var review = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, "Review task");
var failed = await SeedTaskAsync(listId, TaskStatus.Failed, "Failed task");
var cancelled = await SeedTaskAsync(listId, TaskStatus.Cancelled, "Cancelled task");
var idle = await SeedTaskAsync(listId, TaskStatus.Idle, "Idle task");
var handlerTaskId = "handler-1";
_rounds.Save(new HandlerRound(handlerTaskId, "wait",
new[] { done.Id, review.Id, failed.Id, cancelled.Id, idle.Id }, DateTime.UtcNow));
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks(handlerTaskId);
Assert.Empty(result);
}
[Fact]
public async Task RoundWithMixedTasks_ReturnsOnlyQueuedAndRunning()
{
var listId = await SeedListAsync();
var queued = await SeedTaskAsync(listId, TaskStatus.Queued, "Queued task");
var running = await SeedTaskAsync(listId, TaskStatus.Running, "Running task");
var done = await SeedTaskAsync(listId, TaskStatus.Done, "Done task");
var notInRound = await SeedTaskAsync(listId, TaskStatus.Queued, "Unrelated queued task");
var handlerTaskId = "handler-2";
_rounds.Save(new HandlerRound(handlerTaskId, "wait",
new[] { queued.Id, running.Id, done.Id }, DateTime.UtcNow));
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks(handlerTaskId);
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.Id == queued.Id && r.Title == "Queued task");
Assert.Contains(result, r => r.Id == running.Id && r.Title == "Running task");
Assert.DoesNotContain(result, r => r.Id == notInRound.Id);
}
}