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
@@ -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"
},
@@ -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"
},
@@ -116,6 +116,7 @@
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/>
<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>
</Styles.Resources>
@@ -1031,6 +1032,11 @@
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
</Style>
<!-- manual → faint gray: a reminder Claude never touches -->
<Style Selector="Border.badge.manual">
<Setter Property="Background" Value="{DynamicResource ManualBadgeBrush}"/>
</Style>
<!-- ============================================================ -->
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
<!-- ============================================================ -->
+1 -1
View File
@@ -628,7 +628,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 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<string>? SessionSkills = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
@@ -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; }
@@ -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<ListSettingsModalViewModel>();
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<ListSettingsModalViewModel>();
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 */ }
}
@@ -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;
@@ -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);
}
/// <summary>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.</summary>
[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)
{
@@ -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<string> 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();
@@ -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);
@@ -77,6 +77,13 @@
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
IsVisible="{Binding IsMyDay}"
Click="OnRemoveFromMyDayClick"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
IsVisible="{Binding !IsManual}"
Click="OnToggleManualClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
IsVisible="{Binding IsManual}"
Click="OnToggleManualClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
@@ -134,6 +141,10 @@
IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/>
</Border>
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
</StackPanel>
</Grid>
@@ -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)
@@ -60,6 +60,13 @@
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" />
</StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding IsManual, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.manualList}"/>
<TextBlock Text="{loc:Tr modals.listSettings.manualListHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
+4
View File
@@ -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)
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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()
@@ -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()
{