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
@@ -190,6 +190,27 @@ public class MissionControlViewModelTests : IDisposable
await db.SaveChangesAsync();
}
// Mirrors TaskStateService.EnqueueAsync writing to the same DB so the test can verify the
// resulting task state without the real worker process — EnqueueTaskAsync now delegates the
// actual status write to the worker hub instead of doing a raw EF write itself.
private sealed class QueueingWorkerClient : StubWorkerClient
{
private readonly Func<ClaudeDoDbContext> _newContext;
public List<string> QueuedTaskIds { get; } = new();
public QueueingWorkerClient(Func<ClaudeDoDbContext> newContext) => _newContext = newContext;
public override async Task SetTaskStatusAsync(string taskId, TaskStatus status)
{
QueuedTaskIds.Add(taskId);
await using var db = _newContext();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId);
if (entity is null) return;
entity.Status = status;
await db.SaveChangesAsync();
}
}
[Fact]
public async Task EnqueueTaskAsync_SetsTaskQueued_AndShowsInStrip()
{
@@ -200,11 +221,12 @@ public class MissionControlViewModelTests : IDisposable
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
var worker = new QueueingWorkerClient(NewContext);
using var vm = BuildVm(worker);
await vm.EnqueueTaskAsync("idleTask");
Assert.Equal(new[] { "idleTask" }, worker.QueuedTaskIds);
Assert.True(vm.HasQueued);
Assert.Contains(vm.Queued, q => q.Id == "idleTask");
@@ -213,6 +235,28 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(TaskStatus.Queued, entity.Status);
}
[Fact]
public async Task EnqueueTaskAsync_TaskHasOpenConPtySession_DoesNotQueue()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Id = "t1", ListId = "L1", Title = "Do the thing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new QueueingWorkerClient(NewContext);
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
await vm.EnqueueTaskAsync("t1");
Assert.Empty(worker.QueuedTaskIds);
await using var verify = NewContext();
var entity = await verify.Tasks.FirstAsync(t => t.Id == "t1");
Assert.Equal(TaskStatus.Idle, entity.Status);
}
private sealed class ThrowingLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)