fix(ui): gate queueing on an open interactive ConPTY session

A task-based ConPTY session leaves the row Idle in the DB (sessions never
touch status), so nothing stopped the queue picker from claiming it too:
CanSendToQueue ignored HasInteractiveSession, and both TasksIslandViewModel.
SendToQueueAsync and MissionControlViewModel.EnqueueTaskAsync (drag-to-queue)
wrote Status=Queued straight via EF, bypassing TaskStateService entirely and
its manual/draft-child guards. That let an autonomous claude process spawn in
the same worktree a user was hand-editing in the ConPTY pane.

Add !HasInteractiveSession to CanSendToQueue, and route both UI enqueue paths
through IWorkerClient.SetTaskStatusAsync (worker hub -> TaskStateService.
EnqueueAsync) instead of raw EF writes. The interactive-session gate itself
stays in the UI: the worker has no notion of a UI-hosted ConPTY pane.
This commit is contained in:
mika kuns
2026-08-06 13:29:44 +02:00
parent 0d1e3b9a6f
commit d84607f796
8 changed files with 111 additions and 33 deletions
@@ -20,6 +20,7 @@ sealed class FakeWorkerClient : IWorkerClient
public int DiscardPlanningCalls { get; private set; }
public int FinalizePlanningCalls { get; private set; }
public int WakeQueueCalls { get; private set; }
public List<(string TaskId, TaskStatus Status)> SetTaskStatusCalls { get; } = new();
public bool IsConnected => false;
public bool IsReconnecting => false;
@@ -58,7 +59,11 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
public Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
public Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public Task SetTaskStatusAsync(string taskId, TaskStatus status)
{
SetTaskStatusCalls.Add((taskId, status));
return Task.CompletedTask;
}
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
@@ -299,6 +304,29 @@ public class TasksIslandViewModelPlanningTests
Assert.True(child.ParentInView);
Assert.True(child.ShowAsChild);
}
[Fact]
public async Task SendToQueueAsync_RoutesThroughWorkerHub_NotRawEf()
{
var row = MakeRow("t1", TaskStatus.Idle);
var (vm, worker) = VmFactory.Create([row]);
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.SendToQueueCommand).ExecuteAsync(row);
Assert.Equal(("t1", TaskStatus.Queued), Assert.Single(worker.SetTaskStatusCalls));
}
[Fact]
public async Task SendToQueueAsync_TaskHasOpenInteractiveSession_DoesNotQueue()
{
var row = MakeRow("t1", TaskStatus.Idle);
row.HasInteractiveSession = true;
var (vm, worker) = VmFactory.Create([row]);
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.SendToQueueCommand).ExecuteAsync(row);
Assert.Empty(worker.SetTaskStatusCalls);
}
}
// ── My Day add / remove (real DB) ─────────────────────────────────────────────