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:
@@ -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) -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user