diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 4261b3ac..309f4fcc 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -144,6 +144,10 @@ "ctxAddToMyDay": "Zu Mein Tag hinzufügen", "badgeDraft": "ENTWURF", "badgePlanned": "GEPLANT", + "badgeManual": "MANUELL", + "manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf", + "ctxMarkManual": "Als manuell markieren", + "ctxMarkClaudeTask": "Als Claude-Aufgabe markieren", "approve": "Genehmigen", "approveTip": "Genehmigen — als Erledigt markieren", "reject": "Ablehnen", @@ -318,6 +322,8 @@ "workingDirectoryPlaceholder": "(keines)", "browse": "Durchsuchen...", "defaultCommitType": "Standard-Commit-Typ", + "manualList": "Manuelle Liste (Erinnerungen)", + "manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.", "sectionAgent": "AGENT", "resetAgentSettings": "Agent-Einstellungen zurücksetzen" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 88fe7f88..06001fb6 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -144,6 +144,10 @@ "ctxAddToMyDay": "Add to My Day", "badgeDraft": "DRAFT", "badgePlanned": "PLANNED", + "badgeManual": "MANUAL", + "manualTip": "Manual reminder — Claude never picks this up", + "ctxMarkManual": "Mark as manual", + "ctxMarkClaudeTask": "Mark as Claude task", "approve": "Approve", "approveTip": "Approve — mark Done", "reject": "Reject", @@ -318,6 +322,8 @@ "workingDirectoryPlaceholder": "(none)", "browse": "Browse...", "defaultCommitType": "Default commit type", + "manualList": "Manual list (reminders)", + "manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.", "sectionAgent": "AGENT", "resetAgentSettings": "Reset agent settings" }, diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index bf78db41..577515c4 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -116,6 +116,7 @@ + @@ -1031,6 +1032,11 @@ + + + diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index bc6e18e4..7aa83e84 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -628,7 +628,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalB public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList Segments); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); -public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); +public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false); public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs index 80eab9de..55ddda23 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs @@ -12,6 +12,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase [ObservableProperty] private bool _isActive; [ObservableProperty] private string? _workingDir; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; + // Reminder list: tasks created here default to manual. + [ObservableProperty] private bool _isManual; [ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintBelow; public string? IconKey { get; init; } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 3e78a973..15c643f9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -47,7 +47,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable if (row is null || Dialogs is null || _services is null) return; var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id; var vm = _services.GetRequiredService(); - await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType); + await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual); await Dialogs.ShowListSettingsAsync(vm); if (vm.Deleted) await LoadAsync(); else await RefreshRowAsync(row.Id); @@ -243,6 +243,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable DotColorKey = dotColors[idx % dotColors.Length], WorkingDir = l.WorkingDir, DefaultCommitType = l.DefaultCommitType, + IsManual = l.IsManual, }; Items.Add(item); UserLists.Add(item); @@ -317,7 +318,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable if (Dialogs is not null && _services is not null) { var vm = _services.GetRequiredService(); - await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType); + await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual); await Dialogs.ShowListSettingsAsync(vm); if (vm.Deleted) await LoadAsync(); else await RefreshRowAsync(item.Id); @@ -397,6 +398,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable row.Name = entity.Name; row.WorkingDir = entity.WorkingDir; row.DefaultCommitType = entity.DefaultCommitType; + row.IsManual = entity.IsManual; } catch { /* best-effort refresh */ } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index cb3e377e..661b8909 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -35,6 +35,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private bool _parentInView = true; [ObservableProperty] private int _roadblockCount; [ObservableProperty] private bool _isRefining; + // Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden + // and automation skips it; opening a hand-driven ConPTY session stays allowed. + [ObservableProperty] private bool _isManual; // Set by the shell from Mission Control's open ConPTY panes: this task has a live hand-driven // session, which outranks the persisted status on the lifecycle chip (an interactive task is // typically Idle+Active-worktree, i.e. would otherwise read "Parked"). @@ -42,7 +45,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase // Set by the custom drag while this row is being dragged — drives the "grabbed" row style. [ObservableProperty] private bool _isDragging; - public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining; + public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None + && !IsRefining && !IsManual; + + public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null; public DateTime CreatedAt { get; init; } public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d")); @@ -64,7 +70,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase public bool CanOpenPlanningSession => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None - && !IsChild; + && !IsChild + && !IsManual; public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active; // Pick up in a terminal only where a session + worktree reliably still exist: a task @@ -100,7 +107,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase // it must be finalized first. public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks && (!IsChild || ParentFinalized) - && PlanningPhase != PlanningPhase.Active; + && PlanningPhase != PlanningPhase.Active + && !IsManual; // Parent-level "send plan to queue" — only once the plan is finalized (children Planned). // Drives the routing inside SendToQueue, not a separate menu entry. public bool CanQueuePlan => !IsChild && HasPlanningChildren @@ -225,6 +233,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine)); + partial void OnIsManualChanged(bool value) + { + OnPropertyChanged(nameof(ManualBadge)); + OnPropertyChanged(nameof(CanRefine)); + OnPropertyChanged(nameof(CanSendToQueue)); + OnPropertyChanged(nameof(CanOpenPlanningSession)); + } + partial void OnHasInteractiveSessionChanged(bool value) { OnPropertyChanged(nameof(IsParked)); @@ -298,6 +314,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase Done = t.Status == TaskStatus.Done; IsStarred = t.IsStarred; IsMyDay = t.IsMyDay; + IsManual = t.IsManual; Status = t.Status; PlanningPhase = t.PlanningPhase; Branch = t.Worktree?.BranchName; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 65d7c878..c11597d8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -427,6 +427,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return; var listId = _currentList.Id["user:".Length..]; await using var db = await _dbFactory.CreateDbContextAsync(); + // A manual list holds reminders, so tasks created in it start out manual. + var listIsManual = await db.Lists.Where(l => l.Id == listId).Select(l => l.IsManual).FirstOrDefaultAsync(); var entity = new TaskEntity { Id = Guid.NewGuid().ToString("N"), @@ -434,6 +436,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable Title = NewTaskTitle.Trim(), Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + IsManual = listIsManual, }; await new TaskRepository(db).AddAsync(entity); var row = TaskRowViewModel.FromEntity(entity); @@ -622,6 +625,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable TasksChanged?.Invoke(this, EventArgs.Empty); } + /// Flips a task between "Claude can run this" and "manual reminder". A manual task + /// hides every hand-off affordance and is skipped by the queue picker and daily prep. + [RelayCommand] + private async Task ToggleManualAsync(TaskRowViewModel? row) + { + if (row is null) return; + row.IsManual = !row.IsManual; + await using var db = await _dbFactory.CreateDbContextAsync(); + var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); + if (entity != null) + { + entity.IsManual = row.IsManual; + await db.SaveChangesAsync(); + } + TasksChanged?.Invoke(this, EventArgs.Empty); + } + [RelayCommand] private async Task AddToMyDayAsync(TaskRowViewModel? row) { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index 92492ffc..0c6ea2b5 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -28,6 +28,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase [ObservableProperty] private string _name = ""; [ObservableProperty] private string _workingDir = ""; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; + // A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual). + [ObservableProperty] private bool _isManual; public ObservableCollection CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); @@ -49,10 +51,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase string name, string? workingDir, string defaultCommitType, + bool isManual = false, CancellationToken ct = default) { ListId = listId; Name = name; + IsManual = isManual; WorkingDir = workingDir ?? ""; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType; @@ -66,7 +70,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase ListId, string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name, string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir, - DefaultCommitType)); + DefaultCommitType, + IsManual)); await Agent.SaveAsync(); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs index 72d24c00..67c7bb86 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs @@ -56,6 +56,8 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase var candidates = await ctx.Tasks.AsNoTracking() .Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled) .Where(t => t.ListId == _listId) + // Manual tasks are reminders the user owns — never offer them to the handler. + .Where(t => !t.IsManual) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .Select(t => new { t.Id, t.Title, t.Status }) .ToListAsync(ct); diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index a2250059..0a28728e 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -77,6 +77,13 @@ + + + @@ -134,6 +141,10 @@ IsVisible="{Binding IsPlanningParent}"> + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index e713868a..b4acdf9c 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -43,6 +43,12 @@ public partial class TaskRowView : UserControl await vm.ClearScheduleCommand.ExecuteAsync(row); } + private async void OnToggleManualClick(object? sender, RoutedEventArgs e) + { + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + await vm.ToggleManualCommand.ExecuteAsync(row); + } + private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e) { if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) diff --git a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml index 3c4c1a8b..fd68d672 100644 --- a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml @@ -60,6 +60,13 @@ SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}" HorizontalAlignment="Left" MinWidth="160" /> + + + + + diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 11f26a7d..6e52e52d 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 2c187963..9e51b087 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -78,7 +78,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalB public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList 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? SessionSkills = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? 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); diff --git a/src/ClaudeDo.Worker/Queue/QueuePicker.cs b/src/ClaudeDo.Worker/Queue/QueuePicker.cs index 7a0d3ea2..1f127f8a 100644 --- a/src/ClaudeDo.Worker/Queue/QueuePicker.cs +++ b/src/ClaudeDo.Worker/Queue/QueuePicker.cs @@ -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 diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 2557fd26..27633d4f 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -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 IsManualAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct) + => ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId && t.IsManual, ct); + private static async Task IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct) { var parentId = await ctx.Tasks.AsNoTracking() diff --git a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs index 9b929cd7..f220537f 100644 --- a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index 41f06f25..a2ce8a7b 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -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() {