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
@@ -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);
}
}