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:
@@ -185,6 +185,8 @@ public sealed class ExternalMcpService
|
||||
CommitType = list.DefaultCommitType,
|
||||
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
||||
Model = ModelRegistry.NormalizeAlias(model),
|
||||
// A manual list holds reminders, so anything filed there starts out manual.
|
||||
IsManual = list.IsManual,
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
@@ -788,6 +790,8 @@ public sealed class ExternalMcpService
|
||||
var candidates = idle
|
||||
.Where(t => !t.IsMyDay
|
||||
&& t.BlockedByTaskId == null
|
||||
// A manual task is a reminder only the user can do — never a Claude candidate.
|
||||
&& !t.IsManual
|
||||
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.Select(ToCandidate)
|
||||
|
||||
@@ -78,7 +78,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
@@ -503,6 +503,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
entity.Name = dto.Name;
|
||||
entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
|
||||
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
|
||||
entity.IsManual = dto.IsManual;
|
||||
await repo.UpdateAsync(entity);
|
||||
|
||||
await _broadcaster.ListUpdated(dto.Id);
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class QueuePicker : IQueuePicker
|
||||
{
|
||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Eligible task must be Queued, unblocked, and due (or unscheduled).
|
||||
// Eligible task must be Queued, unblocked, not manual, and due (or unscheduled).
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
@@ -27,6 +27,7 @@ public sealed class QueuePicker : IQueuePicker
|
||||
SELECT t.id FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND t.blocked_by_task_id IS NULL
|
||||
AND t.is_manual = 0
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||
LIMIT 1
|
||||
|
||||
@@ -41,6 +41,9 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (await IsDraftChildAsync(ctx, taskId, ct))
|
||||
return new TransitionResult(false, "Draft subtask: finalize the plan before queuing it.");
|
||||
|
||||
if (await IsManualAsync(ctx, taskId, ct))
|
||||
return new TransitionResult(false, "Manual task: mark it as a Claude task before queuing it.");
|
||||
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct);
|
||||
@@ -60,6 +63,9 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (await IsDraftChildAsync(ctx, taskId, ct))
|
||||
return new TransitionResult(false, "Draft subtask: finalize the plan before running it.");
|
||||
|
||||
if (await IsManualAsync(ctx, taskId, ct))
|
||||
return new TransitionResult(false, "Manual task: mark it as a Claude task before running it.");
|
||||
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
@@ -418,6 +424,11 @@ public sealed class TaskStateService : ITaskStateService
|
||||
// A subtask is "draft" only while its planning parent has an open (Active) session.
|
||||
// Improvement children whose parent has PlanningPhase.None are not drafts and may be
|
||||
// queued freely. Standalone tasks (no parent) are never draft.
|
||||
// Server-side backstop for the manual flag: the UI hides the hand-off affordances, but the
|
||||
// MCP surface and hub can still be driven directly.
|
||||
private static Task<bool> IsManualAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
|
||||
=> ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId && t.IsManual, ct);
|
||||
|
||||
private static async Task<bool> IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
|
||||
{
|
||||
var parentId = await ctx.Tasks.AsNoTracking()
|
||||
|
||||
Reference in New Issue
Block a user