feat(tasks): mark tasks and lists as manual

Reminders written down as todos had no home: every task looked like Claude work.
A manual task now shows a MANUAL badge and hides send-to-queue, refine and the
planning session; the queue picker, daily prep and the list handler all skip it,
with a TaskStateService guard so the MCP surface and hub cannot start one either.
Opening a hand-driven ConPTY session stays available on purpose.

A list can be marked manual in its settings, which makes tasks created there
(UI and MCP add_task) start out manual. Toggle per task from its context menu.
This commit is contained in:
Mika Kuns
2026-07-27 15:02:51 +02:00
parent fde9615b34
commit 3a648b7d77
19 changed files with 168 additions and 11 deletions
@@ -48,7 +48,8 @@ public sealed class QueuePickerTests : IDisposable
DateTime? scheduledFor = null,
string? blockedBy = null,
bool taskAgentTag = false,
int? sortOrder = null)
int? sortOrder = null,
bool isManual = false)
{
var task = new TaskEntity
{
@@ -60,6 +61,7 @@ public sealed class QueuePickerTests : IDisposable
ScheduledFor = scheduledFor,
BlockedByTaskId = blockedBy,
CommitType = "feat",
IsManual = isManual,
};
await _tasks.AddAsync(task);
if (sortOrder is not null)
@@ -70,6 +72,28 @@ public sealed class QueuePickerTests : IDisposable
return task;
}
[Fact]
public async Task ClaimNextAsync_Skips_ManualTasks()
{
var listId = await CreateListAsync();
await SeedAsync(listId, isManual: true);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_ManualTask_ButClaimsTheNextOne()
{
var listId = await CreateListAsync();
await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(-5), isManual: true);
var claudeTask = await SeedAsync(listId, createdAt: DateTime.UtcNow);
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(claudeTask.Id, picked!.Id);
}
[Fact]
public async Task ClaimNextAsync_Skips_TasksWithBlockedByTaskId()
{
@@ -41,7 +41,8 @@ public sealed class TaskStateServiceTests : IDisposable
string? parentId = null,
int sortOrder = 0,
string? blockedBy = null,
PlanningPhase phase = PlanningPhase.None)
PlanningPhase phase = PlanningPhase.None,
bool isManual = false)
{
var id = Guid.NewGuid().ToString();
await using var ctx = _factory.CreateDbContext();
@@ -56,6 +57,7 @@ public sealed class TaskStateServiceTests : IDisposable
ParentTaskId = parentId,
SortOrder = sortOrder,
BlockedByTaskId = blockedBy,
IsManual = isManual,
});
await ctx.SaveChangesAsync();
return id;
@@ -89,6 +91,30 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated");
}
[Fact]
public async Task EnqueueAsync_ManualTask_Rejected_AndStaysIdle()
{
var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true);
var result = await _sut.EnqueueAsync(id, default);
Assert.False(result.Ok);
Assert.Contains("Manual task", result.Reason);
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
}
[Fact]
public async Task StartRunningAsync_ManualTask_Rejected_AndStaysIdle()
{
var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true);
var result = await _sut.StartRunningAsync(id, DateTime.UtcNow, default);
Assert.False(result.Ok);
Assert.Contains("Manual task", result.Reason);
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
}
[Fact]
public async Task EnqueueAsync_FromRunning_Rejects_AndDoesNotMutate()
{