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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user