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:
@@ -217,6 +217,10 @@ public sealed record LaunchSpec(
|
|||||||
IReadOnlyList<string> Args,
|
IReadOnlyList<string> Args,
|
||||||
IReadOnlyDictionary<string, string> Env);
|
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 TicketSettingsDto(string? ApiBaseUrl, bool TokenSet);
|
||||||
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
|
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
|
||||||
public record TicketProjectDto(int Id, string Title, string DepartmentName);
|
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.",
|
"enqueueAlreadyOpen": "Kann nicht in die Warteschlange gestellt werden — dieser Task hat eine offene interaktive Sitzung.",
|
||||||
"enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}",
|
"enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}",
|
||||||
"queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {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": {
|
"question": {
|
||||||
"title": "Claude fragt nach",
|
"title": "Claude fragt nach",
|
||||||
"placeholder": "Antwort eingeben…",
|
"placeholder": "Antwort eingeben…",
|
||||||
|
|||||||
@@ -354,6 +354,10 @@
|
|||||||
"enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.",
|
"enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.",
|
||||||
"enqueueFailed": "Queue failed: {0}",
|
"enqueueFailed": "Queue failed: {0}",
|
||||||
"queueRefreshFailed": "Couldn't refresh the queue: {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": {
|
"question": {
|
||||||
"title": "Claude is asking",
|
"title": "Claude is asking",
|
||||||
"placeholder": "Type your answer…",
|
"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
|
/// 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>
|
/// 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);
|
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
|
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -641,6 +641,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||||
=> InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct));
|
=> 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)
|
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
|
=> 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).
|
// 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;
|
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 string _displayTitle;
|
||||||
|
|
||||||
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
|
[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>
|
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
|
||||||
[ObservableProperty] private bool _isSubmitPending;
|
[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>
|
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
|
||||||
public event Action<string>? ErrorReported;
|
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>
|
/// commits the worktree and moves the task to WaitingForReview.</summary>
|
||||||
public event Action<string>? SubmitForReviewRequested;
|
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
|
/// <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
|
/// 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
|
/// 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();
|
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 —
|
// 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.
|
// re-opening the same task would otherwise just re-focus a dead tile.
|
||||||
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
// MissionControlView.OnNewSessionClicked).
|
// MissionControlView.OnNewSessionClicked).
|
||||||
public Action? BrowseForSessionRequested { get; set; }
|
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 UsagePillViewModel UsagePill { get; }
|
||||||
|
|
||||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
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,
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||||
() => DescribeAsync(() => launchSpec(taskId))));
|
() => DescribeAsync(() => launchSpec(taskId))) { IsListHandler = true });
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -416,7 +420,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
catch { /* best-effort title lookup */ }
|
catch { /* best-effort title lookup */ }
|
||||||
|
|
||||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
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
|
// 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.CloseRequested += CloseConPtySession;
|
||||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||||
|
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||||
ConPtySessions.Add(pane);
|
ConPtySessions.Add(pane);
|
||||||
pane.Start();
|
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)
|
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||||
{
|
{
|
||||||
if (!ConPtySessions.Contains(pane)) return;
|
if (!ConPtySessions.Contains(pane)) return;
|
||||||
@@ -502,6 +543,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
pane.CloseRequested -= CloseConPtySession;
|
pane.CloseRequested -= CloseConPtySession;
|
||||||
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||||
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||||
|
pane.AbandonRunRequested -= OnPaneAbandonRun;
|
||||||
ConPtySessions.Remove(pane);
|
ConPtySessions.Remove(pane);
|
||||||
pane.Dispose();
|
pane.Dispose();
|
||||||
}
|
}
|
||||||
@@ -555,6 +597,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
c.CloseRequested -= CloseConPtySession;
|
c.CloseRequested -= CloseConPtySession;
|
||||||
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||||
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||||
|
c.AbandonRunRequested -= OnPaneAbandonRun;
|
||||||
c.Dispose();
|
c.Dispose();
|
||||||
}
|
}
|
||||||
ConPtySessions.Clear();
|
ConPtySessions.Clear();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
Background="{DynamicResource Surface2Brush}"
|
Background="{DynamicResource Surface2Brush}"
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
BorderThickness="0,0,0,1" Padding="8,3">
|
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}"
|
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
ToolTip.Tip="{Binding DisplayTitle}"
|
ToolTip.Tip="{Binding DisplayTitle}"
|
||||||
@@ -25,7 +25,12 @@
|
|||||||
Content="{loc:Tr missionControl.submitForReview}"
|
Content="{loc:Tr missionControl.submitForReview}"
|
||||||
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
|
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
|
||||||
Command="{Binding SubmitForReviewCommand}" />
|
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}"
|
Command="{Binding CloseCommand}"
|
||||||
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
|
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
|
||||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
|
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
@@ -55,6 +56,7 @@ public partial class MissionControlView : UserControl
|
|||||||
_vm.Panes.CollectionChanged -= OnPanesChanged;
|
_vm.Panes.CollectionChanged -= OnPanesChanged;
|
||||||
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||||
_vm.BrowseForSessionRequested = null;
|
_vm.BrowseForSessionRequested = null;
|
||||||
|
_vm.ConfirmAsync = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_vm = DataContext as MissionControlViewModel;
|
_vm = DataContext as MissionControlViewModel;
|
||||||
@@ -64,12 +66,59 @@ public partial class MissionControlView : UserControl
|
|||||||
_vm.Panes.CollectionChanged += OnPanesChanged;
|
_vm.Panes.CollectionChanged += OnPanesChanged;
|
||||||
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
|
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
|
||||||
|
_vm.ConfirmAsync = ShowConfirmAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
RebuildOverviewGrid();
|
RebuildOverviewGrid();
|
||||||
UpdateQueueColumn();
|
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 OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildOverviewGrid();
|
||||||
|
|
||||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
private readonly TicketClientFactory _ticketClients;
|
private readonly TicketClientFactory _ticketClients;
|
||||||
private readonly TicketImportService _ticketImport;
|
private readonly TicketImportService _ticketImport;
|
||||||
private readonly HandlerRestartCoordinator? _handlerRestart;
|
private readonly HandlerRestartCoordinator? _handlerRestart;
|
||||||
|
private readonly HandlerRoundState? _roundState;
|
||||||
|
|
||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
@@ -112,7 +113,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
TicketPatStore ticketPat = null!,
|
TicketPatStore ticketPat = null!,
|
||||||
TicketClientFactory ticketClients = null!,
|
TicketClientFactory ticketClients = null!,
|
||||||
TicketImportService ticketImport = null!,
|
TicketImportService ticketImport = null!,
|
||||||
HandlerRestartCoordinator? handlerRestart = null)
|
HandlerRestartCoordinator? handlerRestart = null,
|
||||||
|
HandlerRoundState? roundState = null)
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_waker = waker;
|
_waker = waker;
|
||||||
@@ -152,6 +154,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_ticketClients = ticketClients;
|
_ticketClients = ticketClients;
|
||||||
_ticketImport = ticketImport;
|
_ticketImport = ticketImport;
|
||||||
_handlerRestart = handlerRestart;
|
_handlerRestart = handlerRestart;
|
||||||
|
_roundState = roundState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
// 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);
|
_ = _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
|
// 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
|
// 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
|
// 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>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||||
=> Task.CompletedTask;
|
=> 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)
|
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -966,4 +966,110 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
vm.ToggleLayoutCommand.Execute(null);
|
vm.ToggleLayoutCommand.Execute(null);
|
||||||
Assert.False(vm.IsFocusMode);
|
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