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:
@@ -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)
|
||||
|
||||
@@ -87,6 +87,21 @@ public class TaskRowViewModelPlanningTests
|
||||
Assert.True(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenInteractiveSession_CannotSendToQueue()
|
||||
{
|
||||
// A hand-driven ConPTY session is still Idle in the DB (sessions never touch status), so
|
||||
// this has to be gated on the UI-only HasInteractiveSession flag, not on Status.
|
||||
var vm = MakeRow(TaskStatus.Idle);
|
||||
Assert.True(vm.CanSendToQueue);
|
||||
|
||||
vm.HasInteractiveSession = true;
|
||||
Assert.False(vm.CanSendToQueue);
|
||||
|
||||
vm.HasInteractiveSession = false;
|
||||
Assert.True(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizedParentWithChildren_CanQueuePlan()
|
||||
{
|
||||
|
||||
@@ -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) ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user